@zhin.js/adapter-milky 0.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/LICENSE +21 -0
- package/README.md +159 -0
- package/lib/adapter.d.ts +25 -0
- package/lib/adapter.d.ts.map +1 -0
- package/lib/adapter.js +94 -0
- package/lib/adapter.js.map +1 -0
- package/lib/api.d.ts +10 -0
- package/lib/api.d.ts.map +1 -0
- package/lib/api.js +48 -0
- package/lib/api.js.map +1 -0
- package/lib/bot-sse.d.ts +31 -0
- package/lib/bot-sse.d.ts.map +1 -0
- package/lib/bot-sse.js +194 -0
- package/lib/bot-sse.js.map +1 -0
- package/lib/bot-webhook.d.ts +34 -0
- package/lib/bot-webhook.d.ts.map +1 -0
- package/lib/bot-webhook.js +187 -0
- package/lib/bot-webhook.js.map +1 -0
- package/lib/bot-ws.d.ts +35 -0
- package/lib/bot-ws.d.ts.map +1 -0
- package/lib/bot-ws.js +243 -0
- package/lib/bot-ws.js.map +1 -0
- package/lib/bot-wss.d.ts +34 -0
- package/lib/bot-wss.d.ts.map +1 -0
- package/lib/bot-wss.js +225 -0
- package/lib/bot-wss.js.map +1 -0
- package/lib/index.d.ts +20 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +27 -0
- package/lib/index.js.map +1 -0
- package/lib/types.d.ts +75 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +5 -0
- package/lib/types.js.map +1 -0
- package/lib/utils.d.ts +35 -0
- package/lib/utils.d.ts.map +1 -0
- package/lib/utils.js +157 -0
- package/lib/utils.js.map +1 -0
- package/package.json +51 -0
- package/src/adapter.ts +115 -0
- package/src/api.ts +63 -0
- package/src/bot-sse.ts +226 -0
- package/src/bot-webhook.ts +222 -0
- package/src/bot-ws.ts +279 -0
- package/src/bot-wss.ts +268 -0
- package/src/eventsource.d.ts +12 -0
- package/src/index.ts +40 -0
- package/src/types.ts +73 -0
- package/src/utils.ts +174 -0
package/src/bot-ws.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Milky WebSocket 正向连接 Bot(应用连协议端 ws(s)://baseUrl/event)
|
|
3
|
+
*/
|
|
4
|
+
import WebSocket from 'ws';
|
|
5
|
+
import { EventEmitter } from 'events';
|
|
6
|
+
import { clearInterval } from 'node:timers';
|
|
7
|
+
import { Bot, Message, SendOptions, segment } from 'zhin.js';
|
|
8
|
+
import { callApi } from './api.js';
|
|
9
|
+
import type { MilkyWsConfig, MilkyEvent } from './types.js';
|
|
10
|
+
import type { MilkyAdapter } from './adapter.js';
|
|
11
|
+
import {
|
|
12
|
+
formatMilkyMessagePayload,
|
|
13
|
+
parseMessageReceiveData,
|
|
14
|
+
toMilkyOutgoingSegments,
|
|
15
|
+
parseMilkyMessageId,
|
|
16
|
+
} from './utils.js';
|
|
17
|
+
|
|
18
|
+
export class MilkyWsClient extends EventEmitter implements Bot<MilkyWsConfig, MilkyEvent> {
|
|
19
|
+
$connected: boolean;
|
|
20
|
+
private ws?: WebSocket;
|
|
21
|
+
private reconnectTimer?: NodeJS.Timeout;
|
|
22
|
+
private heartbeatTimer?: NodeJS.Timeout;
|
|
23
|
+
|
|
24
|
+
get logger() {
|
|
25
|
+
return this.adapter.plugin.logger;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
constructor(public adapter: MilkyAdapter, public $config: MilkyWsConfig) {
|
|
29
|
+
super();
|
|
30
|
+
this.$connected = false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get $id() {
|
|
34
|
+
return this.$config.name;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private get eventUrl(): string {
|
|
38
|
+
const base = this.$config.baseUrl.replace(/\/$/, '');
|
|
39
|
+
const url = base.replace(/^http/, 'ws') + '/event';
|
|
40
|
+
const token = this.$config.access_token;
|
|
41
|
+
if (token) return `${url}${url.includes('?') ? '&' : '?'}access_token=${encodeURIComponent(token)}`;
|
|
42
|
+
return url;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private apiOptions() {
|
|
46
|
+
return { baseUrl: this.$config.baseUrl, access_token: this.$config.access_token };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async $connect(): Promise<void> {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const headers: Record<string, string> = {};
|
|
52
|
+
if (this.$config.access_token) {
|
|
53
|
+
headers['Authorization'] = `Bearer ${this.$config.access_token}`;
|
|
54
|
+
}
|
|
55
|
+
this.ws = new WebSocket(this.eventUrl, { headers });
|
|
56
|
+
|
|
57
|
+
this.ws.on('open', () => {
|
|
58
|
+
this.$connected = true;
|
|
59
|
+
if (!this.$config.access_token) this.logger.warn('missing access_token, connection is not secured');
|
|
60
|
+
this.startHeartbeat();
|
|
61
|
+
resolve();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
this.ws.on('message', (data) => {
|
|
65
|
+
try {
|
|
66
|
+
const event = JSON.parse(data.toString()) as MilkyEvent;
|
|
67
|
+
this.handleEvent(event);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
this.emit('error', error);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
this.ws.on('close', (code, reason) => {
|
|
74
|
+
this.$connected = false;
|
|
75
|
+
reject(new Error(`WS closed: ${code} ${reason.toString()}`));
|
|
76
|
+
this.scheduleReconnect();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
this.ws.on('error', (error) => {
|
|
80
|
+
reject(error);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async $disconnect(): Promise<void> {
|
|
86
|
+
if (this.reconnectTimer) {
|
|
87
|
+
clearTimeout(this.reconnectTimer);
|
|
88
|
+
this.reconnectTimer = undefined;
|
|
89
|
+
}
|
|
90
|
+
if (this.heartbeatTimer) {
|
|
91
|
+
clearInterval(this.heartbeatTimer);
|
|
92
|
+
this.heartbeatTimer = undefined;
|
|
93
|
+
}
|
|
94
|
+
if (this.ws) {
|
|
95
|
+
this.ws.close();
|
|
96
|
+
this.ws = undefined;
|
|
97
|
+
}
|
|
98
|
+
this.$connected = false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private handleEvent(event: MilkyEvent): void {
|
|
102
|
+
const data = parseMessageReceiveData(event);
|
|
103
|
+
if (data) {
|
|
104
|
+
const message = this.$formatMessage(event);
|
|
105
|
+
this.adapter.emit('message.receive', message);
|
|
106
|
+
this.logger.debug(
|
|
107
|
+
`${this.$config.name} recv ${message.$channel.type}(${message.$channel.id}):${segment.raw(message.$content)}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
// 其他 event_type 可在此扩展 Notice / Request
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
$formatMessage(event: MilkyEvent): Message<MilkyEvent> {
|
|
114
|
+
const data = parseMessageReceiveData(event);
|
|
115
|
+
if (!data) {
|
|
116
|
+
return Message.from(event, {
|
|
117
|
+
$id: '',
|
|
118
|
+
$adapter: 'milky',
|
|
119
|
+
$bot: this.$config.name,
|
|
120
|
+
$channel: { id: '', type: 'private' },
|
|
121
|
+
$sender: { id: '', name: '' },
|
|
122
|
+
$content: [],
|
|
123
|
+
$raw: '',
|
|
124
|
+
$timestamp: event.time ?? 0,
|
|
125
|
+
$recall: async () => {},
|
|
126
|
+
$reply: async () => '',
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
const payload = formatMilkyMessagePayload(
|
|
130
|
+
event,
|
|
131
|
+
data,
|
|
132
|
+
(id) => this.$recallMessage(id),
|
|
133
|
+
(channel, content, _quote) =>
|
|
134
|
+
this.adapter.sendMessage({
|
|
135
|
+
...channel,
|
|
136
|
+
context: 'milky',
|
|
137
|
+
bot: this.$config.name,
|
|
138
|
+
content: content as import('zhin.js').SendContent,
|
|
139
|
+
}),
|
|
140
|
+
'milky',
|
|
141
|
+
this.$config.name,
|
|
142
|
+
);
|
|
143
|
+
return Message.from(event, payload);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async $sendMessage(options: SendOptions): Promise<string> {
|
|
147
|
+
const content = Array.isArray(options.content) ? options.content : [options.content];
|
|
148
|
+
const segments = content.map((c) =>
|
|
149
|
+
typeof c === 'string' ? { type: 'text' as const, data: { text: c } } : (c as { type: string; data?: Record<string, unknown> }),
|
|
150
|
+
);
|
|
151
|
+
const message = toMilkyOutgoingSegments(segments);
|
|
152
|
+
if (options.type === 'group') {
|
|
153
|
+
const result = await callApi(this.apiOptions(), 'send_group_message', {
|
|
154
|
+
group_id: parseInt(options.id, 10),
|
|
155
|
+
message,
|
|
156
|
+
});
|
|
157
|
+
const seq = (result as { message_seq?: number }).message_seq;
|
|
158
|
+
this.logger.debug(`${this.$config.name} send group(${options.id}):${segment.raw(options.content)}`);
|
|
159
|
+
return seq != null ? `group:${options.id}:${seq}` : '';
|
|
160
|
+
}
|
|
161
|
+
if (options.type === 'private') {
|
|
162
|
+
const result = await callApi(this.apiOptions(), 'send_private_message', {
|
|
163
|
+
user_id: parseInt(options.id, 10),
|
|
164
|
+
message,
|
|
165
|
+
});
|
|
166
|
+
const seq = (result as { message_seq?: number }).message_seq;
|
|
167
|
+
this.logger.debug(`${this.$config.name} send private(${options.id}):${segment.raw(options.content)}`);
|
|
168
|
+
return seq != null ? `friend:${options.id}:${seq}` : '';
|
|
169
|
+
}
|
|
170
|
+
throw new Error('Either group or private must be provided');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async $recallMessage(id: string): Promise<void> {
|
|
174
|
+
const parsed = parseMilkyMessageId(id);
|
|
175
|
+
if (!parsed) throw new Error(`Invalid message id: ${id}`);
|
|
176
|
+
if (parsed.message_scene === 'group') {
|
|
177
|
+
await callApi(this.apiOptions(), 'recall_group_message', {
|
|
178
|
+
group_id: parsed.peer_id,
|
|
179
|
+
message_seq: parsed.message_seq,
|
|
180
|
+
});
|
|
181
|
+
} else {
|
|
182
|
+
await callApi(this.apiOptions(), 'recall_private_message', {
|
|
183
|
+
user_id: parsed.peer_id,
|
|
184
|
+
message_seq: parsed.message_seq,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async kickMember(groupId: number, userId: number, rejectAddRequest = false): Promise<boolean> {
|
|
190
|
+
await callApi(this.apiOptions(), 'kick_group_member', {
|
|
191
|
+
group_id: groupId,
|
|
192
|
+
user_id: userId,
|
|
193
|
+
reject_add_request: rejectAddRequest,
|
|
194
|
+
});
|
|
195
|
+
this.logger.info(`Milky Bot ${this.$id} 踢出成员 ${userId}(群 ${groupId})`);
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async muteMember(groupId: number, userId: number, duration = 600): Promise<boolean> {
|
|
200
|
+
await callApi(this.apiOptions(), 'set_group_member_mute', {
|
|
201
|
+
group_id: groupId,
|
|
202
|
+
user_id: userId,
|
|
203
|
+
duration,
|
|
204
|
+
});
|
|
205
|
+
this.logger.info(
|
|
206
|
+
`Milky Bot ${this.$id} ${duration > 0 ? `禁言成员 ${userId} ${duration}秒` : `解除禁言 ${userId}`}(群 ${groupId})`,
|
|
207
|
+
);
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async muteAll(groupId: number, enable = true): Promise<boolean> {
|
|
212
|
+
await callApi(this.apiOptions(), 'set_group_whole_mute', { group_id: groupId, is_mute: enable });
|
|
213
|
+
this.logger.info(`Milky Bot ${this.$id} ${enable ? '开启' : '关闭'}全员禁言(群 ${groupId})`);
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async setAdmin(groupId: number, userId: number, enable = true): Promise<boolean> {
|
|
218
|
+
await callApi(this.apiOptions(), 'set_group_member_admin', {
|
|
219
|
+
group_id: groupId,
|
|
220
|
+
user_id: userId,
|
|
221
|
+
is_set: enable,
|
|
222
|
+
});
|
|
223
|
+
this.logger.info(`Milky Bot ${this.$id} ${enable ? '设置' : '取消'}管理员 ${userId}(群 ${groupId})`);
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async setCard(groupId: number, userId: number, card: string): Promise<boolean> {
|
|
228
|
+
await callApi(this.apiOptions(), 'set_group_member_card', {
|
|
229
|
+
group_id: groupId,
|
|
230
|
+
user_id: userId,
|
|
231
|
+
card,
|
|
232
|
+
});
|
|
233
|
+
this.logger.info(`Milky Bot ${this.$id} 设置成员 ${userId} 群名片(群 ${groupId})`);
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async setTitle(groupId: number, userId: number, title: string): Promise<boolean> {
|
|
238
|
+
await callApi(this.apiOptions(), 'set_group_member_special_title', {
|
|
239
|
+
group_id: groupId,
|
|
240
|
+
user_id: userId,
|
|
241
|
+
special_title: title,
|
|
242
|
+
});
|
|
243
|
+
this.logger.info(`Milky Bot ${this.$id} 设置成员 ${userId} 头衔(群 ${groupId})`);
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async setGroupName(groupId: number, name: string): Promise<boolean> {
|
|
248
|
+
await callApi(this.apiOptions(), 'set_group_name', { group_id: groupId, new_group_name: name });
|
|
249
|
+
this.logger.info(`Milky Bot ${this.$id} 设置群名(群 ${groupId})`);
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async getMemberList(groupId: number): Promise<unknown[]> {
|
|
254
|
+
return callApi(this.apiOptions(), 'get_group_member_list', { group_id: groupId }) as Promise<unknown[]>;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async getGroupInfo(groupId: number): Promise<unknown> {
|
|
258
|
+
return callApi(this.apiOptions(), 'get_group_info', { group_id: groupId });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private startHeartbeat(): void {
|
|
262
|
+
const interval = this.$config.heartbeat_interval ?? 30000;
|
|
263
|
+
this.heartbeatTimer = setInterval(() => {
|
|
264
|
+
if (this.ws?.readyState === WebSocket.OPEN) this.ws.ping();
|
|
265
|
+
}, interval);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private scheduleReconnect(): void {
|
|
269
|
+
if (this.reconnectTimer) return;
|
|
270
|
+
const interval = this.$config.reconnect_interval ?? 5000;
|
|
271
|
+
this.reconnectTimer = setTimeout(() => {
|
|
272
|
+
this.reconnectTimer = undefined;
|
|
273
|
+
this.$connect().catch((err) => {
|
|
274
|
+
this.emit('error', err);
|
|
275
|
+
this.scheduleReconnect();
|
|
276
|
+
});
|
|
277
|
+
}, interval);
|
|
278
|
+
}
|
|
279
|
+
}
|
package/src/bot-wss.ts
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Milky 反向 WebSocket Bot:应用开 WS 服务端,协议端来连;鉴权后收事件同正向
|
|
3
|
+
*/
|
|
4
|
+
import { IncomingMessage } from 'http';
|
|
5
|
+
import WebSocket, { WebSocketServer } from 'ws';
|
|
6
|
+
import { EventEmitter } from 'events';
|
|
7
|
+
import { clearInterval } from 'node:timers';
|
|
8
|
+
import { Bot, Message, SendOptions, segment } from 'zhin.js';
|
|
9
|
+
import type { Router } from '@zhin.js/http';
|
|
10
|
+
import { callApi } from './api.js';
|
|
11
|
+
import type { MilkyWssConfig, MilkyEvent } from './types.js';
|
|
12
|
+
import type { MilkyAdapter } from './adapter.js';
|
|
13
|
+
import {
|
|
14
|
+
formatMilkyMessagePayload,
|
|
15
|
+
parseMessageReceiveData,
|
|
16
|
+
toMilkyOutgoingSegments,
|
|
17
|
+
parseMilkyMessageId,
|
|
18
|
+
} from './utils.js';
|
|
19
|
+
|
|
20
|
+
function getAccessTokenFromWsRequest(req: IncomingMessage): string | undefined {
|
|
21
|
+
const auth = req.headers['authorization'];
|
|
22
|
+
if (typeof auth === 'string' && auth.startsWith('Bearer ')) return auth.slice(7);
|
|
23
|
+
const url = req.url ?? '';
|
|
24
|
+
const idx = url.indexOf('?');
|
|
25
|
+
if (idx >= 0) {
|
|
26
|
+
const params = new URLSearchParams(url.slice(idx));
|
|
27
|
+
return params.get('access_token') ?? undefined;
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class MilkyWssServer extends EventEmitter implements Bot<MilkyWssConfig, MilkyEvent> {
|
|
33
|
+
$connected: boolean;
|
|
34
|
+
#wss?: WebSocketServer;
|
|
35
|
+
#client?: WebSocket;
|
|
36
|
+
private heartbeatTimer?: NodeJS.Timeout;
|
|
37
|
+
|
|
38
|
+
get logger() {
|
|
39
|
+
return this.adapter.plugin.logger;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
constructor(
|
|
43
|
+
public adapter: MilkyAdapter,
|
|
44
|
+
public router: Router,
|
|
45
|
+
public $config: MilkyWssConfig,
|
|
46
|
+
) {
|
|
47
|
+
super();
|
|
48
|
+
this.$connected = false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get $id() {
|
|
52
|
+
return this.$config.name;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private apiOptions() {
|
|
56
|
+
return { baseUrl: this.$config.baseUrl, access_token: this.$config.access_token };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async $connect(): Promise<void> {
|
|
60
|
+
const path = this.$config.path.startsWith('/') ? this.$config.path : `/${this.$config.path}`;
|
|
61
|
+
const token = this.$config.access_token;
|
|
62
|
+
if (!token) this.logger.warn('missing access_token, reverse WS is not secured');
|
|
63
|
+
|
|
64
|
+
this.#wss = this.router.ws(path, {
|
|
65
|
+
verifyClient: (info: { req: IncomingMessage }) => {
|
|
66
|
+
const received = getAccessTokenFromWsRequest(info.req);
|
|
67
|
+
if (token && received !== token) {
|
|
68
|
+
this.logger.error('反向 WS 鉴权失败');
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
this.$connected = true;
|
|
76
|
+
this.logger.info(`Milky 反向 WS 服务端已启动: ${path}`);
|
|
77
|
+
|
|
78
|
+
this.#wss.on('connection', (client, req) => {
|
|
79
|
+
this.#client = client;
|
|
80
|
+
this.startHeartbeat();
|
|
81
|
+
this.logger.info(`协议端已连接: ${req.socket?.remoteAddress}`);
|
|
82
|
+
|
|
83
|
+
client.on('message', (data) => {
|
|
84
|
+
try {
|
|
85
|
+
const event = JSON.parse(data.toString()) as MilkyEvent;
|
|
86
|
+
this.handleEvent(event);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
this.emit('error', err);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
client.on('close', () => {
|
|
93
|
+
this.#client = undefined;
|
|
94
|
+
this.logger.warn('协议端断开连接');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
client.on('error', (err) => this.logger.error('反向 WS 连接错误', err));
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async $disconnect(): Promise<void> {
|
|
102
|
+
if (this.heartbeatTimer) {
|
|
103
|
+
clearInterval(this.heartbeatTimer);
|
|
104
|
+
this.heartbeatTimer = undefined;
|
|
105
|
+
}
|
|
106
|
+
this.#wss?.close();
|
|
107
|
+
this.#wss = undefined;
|
|
108
|
+
this.#client = undefined;
|
|
109
|
+
this.$connected = false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
$formatMessage(event: MilkyEvent): Message<MilkyEvent> {
|
|
113
|
+
const data = parseMessageReceiveData(event);
|
|
114
|
+
if (!data) {
|
|
115
|
+
return Message.from(event, {
|
|
116
|
+
$id: '',
|
|
117
|
+
$adapter: 'milky',
|
|
118
|
+
$bot: this.$config.name,
|
|
119
|
+
$channel: { id: '', type: 'private' },
|
|
120
|
+
$sender: { id: '', name: '' },
|
|
121
|
+
$content: [],
|
|
122
|
+
$raw: '',
|
|
123
|
+
$timestamp: event.time ?? 0,
|
|
124
|
+
$recall: async () => {},
|
|
125
|
+
$reply: async () => '',
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
const payload = formatMilkyMessagePayload(
|
|
129
|
+
event,
|
|
130
|
+
data,
|
|
131
|
+
(id) => this.$recallMessage(id),
|
|
132
|
+
(channel, content) =>
|
|
133
|
+
this.adapter.sendMessage({
|
|
134
|
+
...channel,
|
|
135
|
+
context: 'milky',
|
|
136
|
+
bot: this.$config.name,
|
|
137
|
+
content: content as import('zhin.js').SendContent,
|
|
138
|
+
}),
|
|
139
|
+
'milky',
|
|
140
|
+
this.$config.name,
|
|
141
|
+
);
|
|
142
|
+
return Message.from(event, payload);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private handleEvent(event: MilkyEvent): void {
|
|
146
|
+
const data = parseMessageReceiveData(event);
|
|
147
|
+
if (data) {
|
|
148
|
+
const message = this.$formatMessage(event);
|
|
149
|
+
this.adapter.emit('message.receive', message);
|
|
150
|
+
this.logger.debug(
|
|
151
|
+
`${this.$config.name} recv ${message.$channel.type}(${message.$channel.id}):${segment.raw(message.$content)}`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async $sendMessage(options: SendOptions): Promise<string> {
|
|
157
|
+
const content = Array.isArray(options.content) ? options.content : [options.content];
|
|
158
|
+
const segments = content.map((c) =>
|
|
159
|
+
typeof c === 'string' ? { type: 'text' as const, data: { text: c } } : (c as { type: string; data?: Record<string, unknown> }),
|
|
160
|
+
);
|
|
161
|
+
const message = toMilkyOutgoingSegments(segments);
|
|
162
|
+
if (options.type === 'group') {
|
|
163
|
+
const result = await callApi(this.apiOptions(), 'send_group_message', {
|
|
164
|
+
group_id: parseInt(options.id, 10),
|
|
165
|
+
message,
|
|
166
|
+
});
|
|
167
|
+
const seq = (result as { message_seq?: number }).message_seq;
|
|
168
|
+
this.logger.debug(`${this.$config.name} send group(${options.id}):${segment.raw(options.content)}`);
|
|
169
|
+
return seq != null ? `group:${options.id}:${seq}` : '';
|
|
170
|
+
}
|
|
171
|
+
if (options.type === 'private') {
|
|
172
|
+
const result = await callApi(this.apiOptions(), 'send_private_message', {
|
|
173
|
+
user_id: parseInt(options.id, 10),
|
|
174
|
+
message,
|
|
175
|
+
});
|
|
176
|
+
const seq = (result as { message_seq?: number }).message_seq;
|
|
177
|
+
this.logger.debug(`${this.$config.name} send private(${options.id}):${segment.raw(options.content)}`);
|
|
178
|
+
return seq != null ? `friend:${options.id}:${seq}` : '';
|
|
179
|
+
}
|
|
180
|
+
throw new Error('Either group or private must be provided');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async $recallMessage(id: string): Promise<void> {
|
|
184
|
+
const parsed = parseMilkyMessageId(id);
|
|
185
|
+
if (!parsed) throw new Error(`Invalid message id: ${id}`);
|
|
186
|
+
if (parsed.message_scene === 'group') {
|
|
187
|
+
await callApi(this.apiOptions(), 'recall_group_message', {
|
|
188
|
+
group_id: parsed.peer_id,
|
|
189
|
+
message_seq: parsed.message_seq,
|
|
190
|
+
});
|
|
191
|
+
} else {
|
|
192
|
+
await callApi(this.apiOptions(), 'recall_private_message', {
|
|
193
|
+
user_id: parsed.peer_id,
|
|
194
|
+
message_seq: parsed.message_seq,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async kickMember(groupId: number, userId: number, rejectAddRequest = false): Promise<boolean> {
|
|
200
|
+
await callApi(this.apiOptions(), 'kick_group_member', {
|
|
201
|
+
group_id: groupId,
|
|
202
|
+
user_id: userId,
|
|
203
|
+
reject_add_request: rejectAddRequest,
|
|
204
|
+
});
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async muteMember(groupId: number, userId: number, duration = 600): Promise<boolean> {
|
|
209
|
+
await callApi(this.apiOptions(), 'set_group_member_mute', {
|
|
210
|
+
group_id: groupId,
|
|
211
|
+
user_id: userId,
|
|
212
|
+
duration,
|
|
213
|
+
});
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async muteAll(groupId: number, enable = true): Promise<boolean> {
|
|
218
|
+
await callApi(this.apiOptions(), 'set_group_whole_mute', { group_id: groupId, is_mute: enable });
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async setAdmin(groupId: number, userId: number, enable = true): Promise<boolean> {
|
|
223
|
+
await callApi(this.apiOptions(), 'set_group_member_admin', {
|
|
224
|
+
group_id: groupId,
|
|
225
|
+
user_id: userId,
|
|
226
|
+
is_set: enable,
|
|
227
|
+
});
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async setCard(groupId: number, userId: number, card: string): Promise<boolean> {
|
|
232
|
+
await callApi(this.apiOptions(), 'set_group_member_card', {
|
|
233
|
+
group_id: groupId,
|
|
234
|
+
user_id: userId,
|
|
235
|
+
card,
|
|
236
|
+
});
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async setTitle(groupId: number, userId: number, title: string): Promise<boolean> {
|
|
241
|
+
await callApi(this.apiOptions(), 'set_group_member_special_title', {
|
|
242
|
+
group_id: groupId,
|
|
243
|
+
user_id: userId,
|
|
244
|
+
special_title: title,
|
|
245
|
+
});
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async setGroupName(groupId: number, name: string): Promise<boolean> {
|
|
250
|
+
await callApi(this.apiOptions(), 'set_group_name', { group_id: groupId, new_group_name: name });
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async getMemberList(groupId: number): Promise<unknown[]> {
|
|
255
|
+
return callApi(this.apiOptions(), 'get_group_member_list', { group_id: groupId }) as Promise<unknown[]>;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async getGroupInfo(groupId: number): Promise<unknown> {
|
|
259
|
+
return callApi(this.apiOptions(), 'get_group_info', { group_id: groupId });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private startHeartbeat(): void {
|
|
263
|
+
const interval = this.$config.heartbeat_interval ?? 30000;
|
|
264
|
+
this.heartbeatTimer = setInterval(() => {
|
|
265
|
+
if (this.#client?.readyState === WebSocket.OPEN) this.#client.ping();
|
|
266
|
+
}, interval);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
declare module 'eventsource' {
|
|
2
|
+
interface EventSourceConstructor {
|
|
3
|
+
new (url: string, init?: { headers?: Record<string, string> }): EventSource;
|
|
4
|
+
}
|
|
5
|
+
interface EventSource {
|
|
6
|
+
addEventListener(type: string, listener: (e: MessageEvent) => void): void;
|
|
7
|
+
onerror: ((err: unknown) => void) | null;
|
|
8
|
+
close(): void;
|
|
9
|
+
}
|
|
10
|
+
const EventSource: EventSourceConstructor;
|
|
11
|
+
export = EventSource;
|
|
12
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Milky 适配器入口:单一适配器,支持 WS/SSE/Webhook/反向 WS,依赖 router 注册
|
|
3
|
+
*/
|
|
4
|
+
import { usePlugin, type Plugin, type Context } from 'zhin.js';
|
|
5
|
+
import type { Router } from '@zhin.js/http';
|
|
6
|
+
import { MilkyAdapter } from './adapter.js';
|
|
7
|
+
|
|
8
|
+
export * from './types.js';
|
|
9
|
+
export { callApi } from './api.js';
|
|
10
|
+
export * from './utils.js';
|
|
11
|
+
export { MilkyWsClient } from './bot-ws.js';
|
|
12
|
+
export { MilkySseClient } from './bot-sse.js';
|
|
13
|
+
export { MilkyWebhookBot } from './bot-webhook.js';
|
|
14
|
+
export { MilkyWssServer } from './bot-wss.js';
|
|
15
|
+
export { MilkyAdapter, type MilkyBot } from './adapter.js';
|
|
16
|
+
|
|
17
|
+
declare module 'zhin.js' {
|
|
18
|
+
namespace Plugin {
|
|
19
|
+
interface Contexts {
|
|
20
|
+
router: import('@zhin.js/http').Router;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
interface Adapters {
|
|
24
|
+
milky: MilkyAdapter;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { provide } = usePlugin();
|
|
29
|
+
provide({
|
|
30
|
+
name: 'milky',
|
|
31
|
+
description: 'Milky Adapter(WS 正向 / SSE / Webhook / 反向 WS)',
|
|
32
|
+
mounted: async (p: Plugin) => {
|
|
33
|
+
const adapter = new MilkyAdapter(p);
|
|
34
|
+
await adapter.start();
|
|
35
|
+
return adapter;
|
|
36
|
+
},
|
|
37
|
+
dispose: async (adapter: MilkyAdapter) => {
|
|
38
|
+
await adapter.stop();
|
|
39
|
+
},
|
|
40
|
+
} as unknown as Context<'milky'>);
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Milky 适配器类型与配置(与官方文档一致)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** 配置公共字段;单一适配器下 context 均为 'milky',连接方式由 connection 区分 */
|
|
6
|
+
export interface MilkyConfigBase {
|
|
7
|
+
context: 'milky';
|
|
8
|
+
name: string;
|
|
9
|
+
baseUrl: string;
|
|
10
|
+
access_token?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** WebSocket 正向连接 */
|
|
14
|
+
export interface MilkyWsConfig extends MilkyConfigBase {
|
|
15
|
+
connection: 'ws';
|
|
16
|
+
reconnect_interval?: number;
|
|
17
|
+
heartbeat_interval?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** SSE 连接 */
|
|
21
|
+
export interface MilkySseConfig extends MilkyConfigBase {
|
|
22
|
+
connection: 'sse';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Webhook(协议端 POST 到应用) */
|
|
26
|
+
export interface MilkyWebhookConfig extends MilkyConfigBase {
|
|
27
|
+
connection: 'webhook';
|
|
28
|
+
path: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** WebSocket 反向(协议端连应用) */
|
|
32
|
+
export interface MilkyWssConfig extends MilkyConfigBase {
|
|
33
|
+
connection: 'wss';
|
|
34
|
+
path: string;
|
|
35
|
+
heartbeat_interval?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type MilkyBotConfig = MilkyWsConfig | MilkySseConfig | MilkyWebhookConfig | MilkyWssConfig;
|
|
39
|
+
|
|
40
|
+
/** 协议端 API 响应:status、retcode、data?、message? */
|
|
41
|
+
export interface MilkyApiResponse<T = unknown> {
|
|
42
|
+
status: string;
|
|
43
|
+
retcode: number;
|
|
44
|
+
data?: T;
|
|
45
|
+
message?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 事件结构:event_type、time、self_id、data */
|
|
49
|
+
export interface MilkyEvent {
|
|
50
|
+
event_type: string;
|
|
51
|
+
time: number;
|
|
52
|
+
self_id: number;
|
|
53
|
+
data?: Record<string, unknown>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 接收消息 data(message_receive):message_scene、peer_id、message_seq、sender_id、time、segments、可选 friend/group/group_member */
|
|
57
|
+
export interface MilkyIncomingMessage {
|
|
58
|
+
message_scene: 'friend' | 'group' | 'temp';
|
|
59
|
+
peer_id: number;
|
|
60
|
+
message_seq: number;
|
|
61
|
+
sender_id: number;
|
|
62
|
+
time: number;
|
|
63
|
+
segments: MilkyIncomingSegment[];
|
|
64
|
+
friend?: { user_id: number; nickname?: string };
|
|
65
|
+
group?: { group_id: number; group_name?: string };
|
|
66
|
+
group_member?: { user_id: number; nickname?: string; card?: string; role?: string };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 接收消息段:type、data */
|
|
70
|
+
export interface MilkyIncomingSegment {
|
|
71
|
+
type: string;
|
|
72
|
+
data?: Record<string, unknown>;
|
|
73
|
+
}
|