@kyyinfinite/lumina 1.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 +629 -0
- package/examples/ai-rich.js +84 -0
- package/examples/button.js +57 -0
- package/examples/carousel.js +51 -0
- package/examples/interactive.js +102 -0
- package/examples/media.js +66 -0
- package/examples/simple-bot.js +56 -0
- package/package.json +86 -0
- package/src/builders/ai-rich.js +644 -0
- package/src/builders/base.js +109 -0
- package/src/builders/button-v2.js +159 -0
- package/src/builders/button.js +398 -0
- package/src/builders/card.js +168 -0
- package/src/builders/carousel.js +122 -0
- package/src/builders/index.d.ts +1 -0
- package/src/builders/index.js +13 -0
- package/src/client/bot.js +192 -0
- package/src/client/connection.js +180 -0
- package/src/errors.js +88 -0
- package/src/index.d.ts +458 -0
- package/src/index.js +152 -0
- package/src/media/fetch.js +67 -0
- package/src/media/image.js +86 -0
- package/src/media/index.d.ts +1 -0
- package/src/media/index.js +12 -0
- package/src/media/resolver.js +115 -0
- package/src/media/uploader.js +65 -0
- package/src/media/video.js +195 -0
- package/src/parsers/code-tokenizer-keywords.js +128 -0
- package/src/parsers/code-tokenizer.js +191 -0
- package/src/parsers/index.d.ts +1 -0
- package/src/parsers/index.js +11 -0
- package/src/parsers/inline-entity.js +231 -0
- package/src/parsers/table-metadata.js +69 -0
- package/src/proto/enums.js +170 -0
- package/src/proto/index.d.ts +1 -0
- package/src/proto/index.js +13 -0
- package/src/proto/layouts.js +89 -0
- package/src/proto/primitives.js +181 -0
- package/src/proto/relay-nodes.js +55 -0
- package/src/proto/rich-response.js +144 -0
- package/src/proto/updater.js +318 -0
- package/src/services/index.d.ts +1 -0
- package/src/services/index.js +10 -0
- package/src/services/media-service.js +184 -0
- package/src/services/message-service.js +288 -0
- package/src/services/proto-service.js +90 -0
- package/src/utils/id.js +42 -0
- package/src/utils/logger.js +65 -0
- package/src/utils/mime.js +104 -0
- package/src/utils/promise.js +52 -0
- package/src/utils/validator.js +129 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file message-service.js
|
|
3
|
+
* @module lumina/services/message-service
|
|
4
|
+
*
|
|
5
|
+
* High-level message senders for every "basic" WhatsApp content type:
|
|
6
|
+
* text, image, video, audio, document, sticker, contact, location, poll,
|
|
7
|
+
* reply, react, delete, forward, copy, edit.
|
|
8
|
+
*
|
|
9
|
+
* Each method delegates to {@link Connection} so that the Bot wrapper stays
|
|
10
|
+
* thin and the service layer never imports Baileys directly (Layer 3 must
|
|
11
|
+
* not touch the adapter's internals).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { coerceMediaSource } from '../utils/validator.js'
|
|
15
|
+
|
|
16
|
+
/** @typedef {import('../client/connection.js').Connection} Connection */
|
|
17
|
+
/** @typedef {import('./proto-service.js').ProtoService} ProtoService */
|
|
18
|
+
/** @typedef {import('./media-service.js').MediaService} MediaService */
|
|
19
|
+
|
|
20
|
+
export class MessageService {
|
|
21
|
+
/**
|
|
22
|
+
* @param {Connection} conn
|
|
23
|
+
* @param {ProtoService} proto
|
|
24
|
+
* @param {MediaService} media
|
|
25
|
+
*/
|
|
26
|
+
constructor(conn, proto, media) {
|
|
27
|
+
this.conn = conn
|
|
28
|
+
this.proto = proto
|
|
29
|
+
this.media = media
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} jid
|
|
34
|
+
* @param {string} text
|
|
35
|
+
* @param {object} [opts] Forwarded to generateWAMessageFromContent.
|
|
36
|
+
* @returns {Promise<object>}
|
|
37
|
+
*/
|
|
38
|
+
async text(jid, text, opts) {
|
|
39
|
+
const msg = await this.conn.generateMessage(jid, { conversation: text }, opts ?? {})
|
|
40
|
+
await this.conn.relayMessage(msg.key.remoteJid, msg.message, {
|
|
41
|
+
messageId: msg.key.id,
|
|
42
|
+
...(opts ?? {}),
|
|
43
|
+
})
|
|
44
|
+
return msg
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @param {string | Buffer} source */
|
|
48
|
+
async image(jid, source, caption, opts = {}) {
|
|
49
|
+
return this.#sendMedia('image', jid, source, caption, opts)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @param {string | Buffer} source */
|
|
53
|
+
async video(jid, source, caption, opts = {}) {
|
|
54
|
+
return this.#sendMedia('video', jid, source, caption, opts)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @param {string | Buffer} source */
|
|
58
|
+
async audio(jid, source, opts = {}) {
|
|
59
|
+
return this.#sendMedia('audio', jid, source, undefined, { ...opts, ptt: opts.ptt ?? false })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @param {string | Buffer} source */
|
|
63
|
+
async document(jid, source, opts = {}) {
|
|
64
|
+
return this.#sendMedia('document', jid, source, undefined, opts)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @param {string | Buffer} source */
|
|
68
|
+
async sticker(jid, source, opts = {}) {
|
|
69
|
+
return this.#sendMedia('sticker', jid, source, undefined, opts)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {'image'|'video'|'audio'|'document'|'sticker'} type
|
|
74
|
+
* @param {string} jid
|
|
75
|
+
* @param {string | Buffer} source
|
|
76
|
+
* @param {string} [caption]
|
|
77
|
+
* @param {object} opts
|
|
78
|
+
*/
|
|
79
|
+
async #sendMedia(type, jid, source, caption, opts = {}) {
|
|
80
|
+
const { raw } = coerceMediaSource(source)
|
|
81
|
+
const msg = await this.conn.generateMessage(
|
|
82
|
+
jid,
|
|
83
|
+
{ [type]: raw, caption, ...opts },
|
|
84
|
+
opts,
|
|
85
|
+
)
|
|
86
|
+
await this.conn.relayMessage(msg.key.remoteJid, msg.message, {
|
|
87
|
+
messageId: msg.key.id,
|
|
88
|
+
...opts,
|
|
89
|
+
})
|
|
90
|
+
return msg
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Send one or more contacts.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} jid
|
|
97
|
+
* @param {Array<{ name: string, number: string }>} contacts
|
|
98
|
+
* @param {object} [opts]
|
|
99
|
+
*/
|
|
100
|
+
async contact(jid, contacts, opts = {}) {
|
|
101
|
+
const vCard = (c) =>
|
|
102
|
+
`BEGIN:VCARD\nVERSION:3.0\nFN:${c.name}\nTEL;type=CELL;type=VOICE;waid=${c.number}:${c.number}\nEND:VCARD`
|
|
103
|
+
|
|
104
|
+
if (contacts.length === 1) {
|
|
105
|
+
const msg = await this.conn.generateMessage(
|
|
106
|
+
jid,
|
|
107
|
+
{
|
|
108
|
+
contactMessage: {
|
|
109
|
+
displayName: contacts[0].name,
|
|
110
|
+
vcard: vCard(contacts[0]),
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
opts,
|
|
114
|
+
)
|
|
115
|
+
await this.conn.relayMessage(msg.key.remoteJid, msg.message, {
|
|
116
|
+
messageId: msg.key.id,
|
|
117
|
+
...opts,
|
|
118
|
+
})
|
|
119
|
+
return msg
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Multi-contact: use contactsMessage
|
|
123
|
+
const multi = await this.conn.generateMessage(
|
|
124
|
+
jid,
|
|
125
|
+
{
|
|
126
|
+
contactsMessage: {
|
|
127
|
+
displayName: contacts.map((c) => c.name).join(', '),
|
|
128
|
+
contacts: contacts.map((c) => ({ vcard: vCard(c) })),
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
opts,
|
|
132
|
+
)
|
|
133
|
+
await this.conn.relayMessage(multi.key.remoteJid, multi.message, {
|
|
134
|
+
messageId: multi.key.id,
|
|
135
|
+
...opts,
|
|
136
|
+
})
|
|
137
|
+
return multi
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @param {string} jid
|
|
142
|
+
* @param {number} lat
|
|
143
|
+
* @param {number} lng
|
|
144
|
+
* @param {object} [opts] Optional name/address/urls.
|
|
145
|
+
*/
|
|
146
|
+
async location(jid, lat, lng, opts = {}) {
|
|
147
|
+
const msg = await this.conn.generateMessage(
|
|
148
|
+
jid,
|
|
149
|
+
{
|
|
150
|
+
locationMessage: {
|
|
151
|
+
degreesLatitude: lat,
|
|
152
|
+
degreesLongitude: lng,
|
|
153
|
+
name: opts.name,
|
|
154
|
+
address: opts.address,
|
|
155
|
+
url: opts.url,
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
opts,
|
|
159
|
+
)
|
|
160
|
+
await this.conn.relayMessage(msg.key.remoteJid, msg.message, {
|
|
161
|
+
messageId: msg.key.id,
|
|
162
|
+
...opts,
|
|
163
|
+
})
|
|
164
|
+
return msg
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @param {string} jid
|
|
169
|
+
* @param {string} name
|
|
170
|
+
* @param {string[]} options
|
|
171
|
+
* @param {object} [opts] { selectableCount, public, messageSecret }
|
|
172
|
+
*/
|
|
173
|
+
async poll(jid, name, options, opts = {}) {
|
|
174
|
+
const msg = await this.conn.generatePoll(jid, {
|
|
175
|
+
name,
|
|
176
|
+
values: options,
|
|
177
|
+
selectableCount: opts.selectableCount ?? 1,
|
|
178
|
+
toJid: jid,
|
|
179
|
+
})
|
|
180
|
+
await this.conn.relayMessage(msg.key.remoteJid, msg.message, {
|
|
181
|
+
messageId: msg.key.id,
|
|
182
|
+
...opts,
|
|
183
|
+
})
|
|
184
|
+
return msg
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Reply to a quoted message.
|
|
189
|
+
*
|
|
190
|
+
* @param {string} jid
|
|
191
|
+
* @param {string} text
|
|
192
|
+
* @param {object} quoted Quoted message object (must have `.key`).
|
|
193
|
+
* @param {object} [opts]
|
|
194
|
+
*/
|
|
195
|
+
async reply(jid, text, quoted, opts = {}) {
|
|
196
|
+
const msg = await this.conn.generateMessage(
|
|
197
|
+
jid,
|
|
198
|
+
{
|
|
199
|
+
extendedTextMessage: {
|
|
200
|
+
text,
|
|
201
|
+
contextInfo: {
|
|
202
|
+
stanzaId: quoted.key.id,
|
|
203
|
+
participant: quoted.key.participant ?? quoted.key.remoteJid,
|
|
204
|
+
quotedMessage: quoted.message ?? { conversation: '' },
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
{ quoted, ...opts },
|
|
209
|
+
)
|
|
210
|
+
await this.conn.relayMessage(msg.key.remoteJid, msg.message, {
|
|
211
|
+
messageId: msg.key.id,
|
|
212
|
+
...opts,
|
|
213
|
+
})
|
|
214
|
+
return msg
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* React to a message.
|
|
219
|
+
*
|
|
220
|
+
* @param {string} jid
|
|
221
|
+
* @param {object} key
|
|
222
|
+
* @param {string} emoji
|
|
223
|
+
* @param {object} [opts]
|
|
224
|
+
*/
|
|
225
|
+
async react(jid, key, emoji, opts = {}) {
|
|
226
|
+
const msg = await this.conn.generateReaction(jid, { key, text: emoji })
|
|
227
|
+
await this.conn.relayMessage(msg.key.remoteJid, msg.message, {
|
|
228
|
+
messageId: msg.key.id,
|
|
229
|
+
...opts,
|
|
230
|
+
})
|
|
231
|
+
return msg
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Delete a message (revoke).
|
|
236
|
+
*
|
|
237
|
+
* @param {string} jid
|
|
238
|
+
* @param {object} key
|
|
239
|
+
*/
|
|
240
|
+
async delete(jid, key) {
|
|
241
|
+
return this.conn.raw.sendMessage(jid, { delete: key })
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Forward a message.
|
|
246
|
+
*
|
|
247
|
+
* @param {string} jid
|
|
248
|
+
* @param {object} message The message object to forward.
|
|
249
|
+
* @param {object} [opts] { quoted, additionalNodes }
|
|
250
|
+
*/
|
|
251
|
+
async forward(jid, message, opts = {}) {
|
|
252
|
+
return this.conn.raw.forwardMessage(jid, message, opts)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Copy a message (re-send its content as a new message).
|
|
257
|
+
*
|
|
258
|
+
* @param {string} jid
|
|
259
|
+
* @param {object} message
|
|
260
|
+
* @param {object} [opts]
|
|
261
|
+
*/
|
|
262
|
+
async copy(jid, message, opts = {}) {
|
|
263
|
+
const msg = await this.conn.generateMessage(jid, message, opts)
|
|
264
|
+
await this.conn.relayMessage(msg.key.remoteJid, msg.message, {
|
|
265
|
+
messageId: msg.key.id,
|
|
266
|
+
...opts,
|
|
267
|
+
})
|
|
268
|
+
return msg
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Edit a previously sent text message.
|
|
273
|
+
*
|
|
274
|
+
* @param {string} jid
|
|
275
|
+
* @param {object} key
|
|
276
|
+
* @param {string} newText
|
|
277
|
+
* @param {object} [opts]
|
|
278
|
+
*/
|
|
279
|
+
async edit(jid, key, newText, opts = {}) {
|
|
280
|
+
return this.conn.raw.sendMessage(jid, {
|
|
281
|
+
edit: key,
|
|
282
|
+
text: newText,
|
|
283
|
+
...opts,
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export default MessageService
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file proto-service.js
|
|
3
|
+
* @module lumina/services/proto-service
|
|
4
|
+
*
|
|
5
|
+
* Wraps the lower-level `proto/*` modules with a {@link Connection} so that
|
|
6
|
+
* callers don't have to thread the socket through every call.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createInteractiveNodes } from '../proto/relay-nodes.js'
|
|
10
|
+
|
|
11
|
+
/** @typedef {import('../client/connection.js').Connection} Connection */
|
|
12
|
+
|
|
13
|
+
export class ProtoService {
|
|
14
|
+
/** @param {Connection} conn */
|
|
15
|
+
constructor(conn) {
|
|
16
|
+
if (!conn) throw new Error('Connection is required for ProtoService')
|
|
17
|
+
this.conn = conn
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Generate a WA message object from a content descriptor.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} jid
|
|
24
|
+
* @param {object} content
|
|
25
|
+
* @param {object} [opts]
|
|
26
|
+
* @returns {Promise<object>}
|
|
27
|
+
*/
|
|
28
|
+
async generate(jid, content, opts) {
|
|
29
|
+
return this.conn.generateMessage(jid, content, opts ?? {})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Relay a pre-built message. Returns the message ID.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} jid
|
|
36
|
+
* @param {object} message
|
|
37
|
+
* @param {object} [opts]
|
|
38
|
+
* @returns {Promise<string>}
|
|
39
|
+
*/
|
|
40
|
+
async relay(jid, message, opts) {
|
|
41
|
+
return this.conn.relayMessage(jid, message, opts ?? {})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Relay a pre-built message WITH the biz/native_flow/mixed additional
|
|
46
|
+
* nodes. This is the variant used by every interactive builder (Button,
|
|
47
|
+
* ButtonV2, Carousel).
|
|
48
|
+
*
|
|
49
|
+
* @param {string} jid
|
|
50
|
+
* @param {object} message
|
|
51
|
+
* @param {object} [opts]
|
|
52
|
+
* @returns {Promise<string>}
|
|
53
|
+
*/
|
|
54
|
+
async relayInteractive(jid, message, opts = {}) {
|
|
55
|
+
const nodes = opts.additionalNodes ?? createInteractiveNodes()
|
|
56
|
+
return this.conn.relayMessage(jid, message, {
|
|
57
|
+
...opts,
|
|
58
|
+
additionalNodes: nodes,
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Generate + relay in one shot. Returns the generated message object
|
|
64
|
+
* (with `.key` populated by Baileys) so callers can keep a reference.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} jid
|
|
67
|
+
* @param {object} content
|
|
68
|
+
* @param {object} [opts]
|
|
69
|
+
* @param {boolean} [opts.interactive=false] If true, attach the biz/native_flow nodes.
|
|
70
|
+
* @returns {Promise<object>}
|
|
71
|
+
*/
|
|
72
|
+
async generateAndRelay(jid, content, opts = {}) {
|
|
73
|
+
const { interactive = false, ...rest } = opts
|
|
74
|
+
const msg = await this.conn.generateMessage(jid, content, rest)
|
|
75
|
+
if (interactive) {
|
|
76
|
+
await this.relayInteractive(msg.key.remoteJid, msg.message, {
|
|
77
|
+
messageId: msg.key.id,
|
|
78
|
+
...rest,
|
|
79
|
+
})
|
|
80
|
+
} else {
|
|
81
|
+
await this.relay(msg.key.remoteJid, msg.message, {
|
|
82
|
+
messageId: msg.key.id,
|
|
83
|
+
...rest,
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
return msg
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export default ProtoService
|
package/src/utils/id.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file id.js
|
|
3
|
+
* @module lumina/utils/id
|
|
4
|
+
*
|
|
5
|
+
* Stable, collision-resistant identifier generation. Replaces the obfuscated
|
|
6
|
+
* `\u004E\u0049\u0058\u0045\u004C_…` prefix that was sprinkled across the
|
|
7
|
+
* legacy extractIE function.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import crypto from 'node:crypto'
|
|
11
|
+
|
|
12
|
+
/** Project-wide prefix for inline-entity keys. */
|
|
13
|
+
export const ENTITY_PREFIX = 'LUMINA'
|
|
14
|
+
|
|
15
|
+
/** Generate a fresh RFC-4122 v4 UUID. */
|
|
16
|
+
export function uuid() {
|
|
17
|
+
return crypto.randomUUID()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Build a stable, scoped inline-entity key.
|
|
22
|
+
*
|
|
23
|
+
* @param {'HYPERLINK'|'CITATION'|'LATEX'} kind
|
|
24
|
+
* @param {number} index Zero-based counter.
|
|
25
|
+
* @returns {string} e.g. `LUMINA_HYPERLINK_0`
|
|
26
|
+
*/
|
|
27
|
+
export function entityKey(kind, index) {
|
|
28
|
+
return `${ENTITY_PREFIX}_${kind}_${index}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Hash a string with SHA-256, return hex digest (used by ProtoUpdater for
|
|
33
|
+
* backup integrity verification).
|
|
34
|
+
*
|
|
35
|
+
* @param {string} content
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
export function sha256(content) {
|
|
39
|
+
return crypto.createHash('sha256').update(content).digest('hex')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default { uuid, entityKey, sha256, ENTITY_PREFIX }
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file logger.js
|
|
3
|
+
* @module lumina/utils/logger
|
|
4
|
+
*
|
|
5
|
+
* Minimal, zero-dependency logger with child scopes and level filtering.
|
|
6
|
+
* Compatible with the pino/winston subset: { error, warn, info, debug, child }.
|
|
7
|
+
*
|
|
8
|
+
* Default logger is a no-op — zero overhead in production. Inject your own
|
|
9
|
+
* logger via `new Bot(socket, { logger })` to enable output.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** @typedef {'silent'|'error'|'warn'|'info'|'debug'} LogLevel */
|
|
13
|
+
|
|
14
|
+
const LEVEL_ORDER = /** @type {const} */ ({
|
|
15
|
+
silent: 0,
|
|
16
|
+
error: 1,
|
|
17
|
+
warn: 2,
|
|
18
|
+
info: 3,
|
|
19
|
+
debug: 4,
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const NOOP = () => {}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build a logger from a single options object.
|
|
26
|
+
*
|
|
27
|
+
* @param {object} [opts]
|
|
28
|
+
* @param {LogLevel} [opts.level='warn'] Minimum level emitted.
|
|
29
|
+
* @param {boolean} [opts.silent=false] Convenience: forces level 'silent'.
|
|
30
|
+
* @param {(level: string, scope: string, args: any[]) => void} [opts.transport]
|
|
31
|
+
* Custom transport. Receives the level name, the current scope, and the
|
|
32
|
+
* raw arguments array. Default transport writes to console.
|
|
33
|
+
* @param {string} [opts.scope='lumina'] Initial scope.
|
|
34
|
+
* @returns {Logger}
|
|
35
|
+
*/
|
|
36
|
+
export function createLogger(opts = {}) {
|
|
37
|
+
const level = opts.silent ? 'silent' : opts.level ?? 'warn'
|
|
38
|
+
const threshold = LEVEL_ORDER[level] ?? LEVEL_ORDER.warn
|
|
39
|
+
const scope0 = opts.scope ?? 'lumina'
|
|
40
|
+
const transport =
|
|
41
|
+
opts.transport ??
|
|
42
|
+
((lvl, scope, args) => {
|
|
43
|
+
const fn = lvl === 'error' ? console.error : lvl === 'warn' ? console.warn : console.log
|
|
44
|
+
fn(`[${lvl}] ${scope}:`, ...args)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const make = (scope) => {
|
|
48
|
+
const emit = (lvl, args) => {
|
|
49
|
+
if (LEVEL_ORDER[lvl] <= threshold) transport(lvl, scope, args)
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
error: (...a) => emit('error', a),
|
|
53
|
+
warn: (...a) => emit('warn', a),
|
|
54
|
+
info: (...a) => emit('info', a),
|
|
55
|
+
debug: (...a) => emit('debug', a),
|
|
56
|
+
child: (sub) => make(sub ? `${scope}:${sub}` : scope),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return make(scope0)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @typedef {ReturnType<createLogger>} Logger */
|
|
64
|
+
|
|
65
|
+
export default createLogger
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file mime.js
|
|
3
|
+
* @module lumina/utils/mime
|
|
4
|
+
*
|
|
5
|
+
* Sniff & lookup MIME types from buffer magic bytes and file extensions.
|
|
6
|
+
* Replaces the hardcoded `mime_type: 'image/png'` / `'video/mp4'` literals
|
|
7
|
+
* sprinkled across the legacy AIRich builder.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** @type {Record<string, string>} */
|
|
11
|
+
const EXT_MAP = {
|
|
12
|
+
png: 'image/png',
|
|
13
|
+
jpg: 'image/jpeg',
|
|
14
|
+
jpeg: 'image/jpeg',
|
|
15
|
+
gif: 'image/gif',
|
|
16
|
+
webp: 'image/webp',
|
|
17
|
+
bmp: 'image/bmp',
|
|
18
|
+
tiff: 'image/tiff',
|
|
19
|
+
mp4: 'video/mp4',
|
|
20
|
+
webm: 'video/webm',
|
|
21
|
+
mov: 'video/quicktime',
|
|
22
|
+
avi: 'video/x-msvideo',
|
|
23
|
+
mkv: 'video/x-matroska',
|
|
24
|
+
mp3: 'audio/mpeg',
|
|
25
|
+
ogg: 'audio/ogg',
|
|
26
|
+
wav: 'audio/wav',
|
|
27
|
+
opus: 'audio/opus',
|
|
28
|
+
m4a: 'audio/mp4',
|
|
29
|
+
pdf: 'application/pdf',
|
|
30
|
+
json: 'application/json',
|
|
31
|
+
zip: 'application/zip',
|
|
32
|
+
txt: 'text/plain',
|
|
33
|
+
html: 'text/html',
|
|
34
|
+
csv: 'text/csv',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Sniff MIME type from buffer magic bytes. Falls back to extension lookup.
|
|
39
|
+
*
|
|
40
|
+
* @param {Buffer} buf
|
|
41
|
+
* @param {string} [filename] Optional filename for extension fallback.
|
|
42
|
+
* @returns {string} MIME type, or 'application/octet-stream' if unknown.
|
|
43
|
+
*/
|
|
44
|
+
export function sniffMime(buf, filename) {
|
|
45
|
+
if (!Buffer.isBuffer(buf) || buf.length < 4) {
|
|
46
|
+
return filename ? fromExtension(filename) : 'application/octet-stream'
|
|
47
|
+
}
|
|
48
|
+
// PNG
|
|
49
|
+
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return 'image/png'
|
|
50
|
+
// JPEG (FFD8FF)
|
|
51
|
+
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg'
|
|
52
|
+
// GIF (GIF87a / GIF89a)
|
|
53
|
+
if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return 'image/gif'
|
|
54
|
+
// WebP — RIFF....WEBP
|
|
55
|
+
if (
|
|
56
|
+
buf.length >= 12 &&
|
|
57
|
+
buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 &&
|
|
58
|
+
buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50
|
|
59
|
+
) return 'image/webp'
|
|
60
|
+
// MP4 — ftyp box at offset 4
|
|
61
|
+
if (buf.length >= 12 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) {
|
|
62
|
+
const brand = buf.toString('ascii', 8, 12)
|
|
63
|
+
if (brand === 'qt ') return 'video/quicktime'
|
|
64
|
+
if (brand === 'M4A ') return 'audio/mp4'
|
|
65
|
+
return 'video/mp4'
|
|
66
|
+
}
|
|
67
|
+
// MP3 — ID3 tag or frame sync
|
|
68
|
+
if (buf[0] === 0x49 && buf[1] === 0x44 && buf[2] === 0x33) return 'audio/mpeg'
|
|
69
|
+
if (buf[0] === 0xff && (buf[1] & 0xe0) === 0xe0) return 'audio/mpeg'
|
|
70
|
+
// OGG
|
|
71
|
+
if (buf[0] === 0x4f && buf[1] === 0x67 && buf[2] === 0x67 && buf[3] === 0x53) return 'audio/ogg'
|
|
72
|
+
// WAV — RIFF....WAVE
|
|
73
|
+
if (
|
|
74
|
+
buf.length >= 12 &&
|
|
75
|
+
buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 &&
|
|
76
|
+
buf[8] === 0x57 && buf[9] === 0x41 && buf[10] === 0x56 && buf[11] === 0x45
|
|
77
|
+
) return 'audio/wav'
|
|
78
|
+
// PDF
|
|
79
|
+
if (buf[0] === 0x25 && buf[1] === 0x50 && buf[2] === 0x44 && buf[3] === 0x46) return 'application/pdf'
|
|
80
|
+
// ZIP
|
|
81
|
+
if (buf[0] === 0x50 && buf[1] === 0x4b && (buf[2] === 0x03 || buf[2] === 0x05 || buf[2] === 0x07)) {
|
|
82
|
+
return 'application/zip'
|
|
83
|
+
}
|
|
84
|
+
return filename ? fromExtension(filename) : 'application/octet-stream'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** @param {string} filename */
|
|
88
|
+
function fromExtension(filename) {
|
|
89
|
+
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
|
|
90
|
+
return EXT_MAP[ext] ?? 'application/octet-stream'
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {string} mime
|
|
95
|
+
* @returns {'image'|'video'|'audio'|'document'} WA media category.
|
|
96
|
+
*/
|
|
97
|
+
export function mimeToCategory(mime) {
|
|
98
|
+
if (mime.startsWith('image/')) return 'image'
|
|
99
|
+
if (mime.startsWith('video/')) return 'video'
|
|
100
|
+
if (mime.startsWith('audio/')) return 'audio'
|
|
101
|
+
return 'document'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export default { sniffMime, mimeToCategory, EXT_MAP }
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file promise.js
|
|
3
|
+
* @module lumina/utils/promise
|
|
4
|
+
*
|
|
5
|
+
* Promise helpers. The legacy `waitAllPromises` deep-resolver is preserved
|
|
6
|
+
* here as `resolveDeep`, used only for backward-compat / lazy builder mode.
|
|
7
|
+
* The default builder path uses eager await — see AIRichBuilder docs.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Recursively walk an object/array tree, awaiting every Promise encountered.
|
|
12
|
+
* Returns a structurally-identical tree with all Promises resolved.
|
|
13
|
+
*
|
|
14
|
+
* @template T
|
|
15
|
+
* @param {T | Promise<T>} input
|
|
16
|
+
* @returns {Promise<Awaited<T>>}
|
|
17
|
+
*/
|
|
18
|
+
export async function resolveDeep(input) {
|
|
19
|
+
const seen = await input
|
|
20
|
+
if (seen && typeof seen.then === 'function') return resolveDeep(seen)
|
|
21
|
+
if (Array.isArray(seen)) return Promise.all(seen.map(resolveDeep))
|
|
22
|
+
if (seen && typeof seen === 'object') {
|
|
23
|
+
const entries = Object.entries(seen)
|
|
24
|
+
const resolved = await Promise.all(entries.map(async ([k, v]) => [k, await resolveDeep(v)]))
|
|
25
|
+
return Object.fromEntries(resolved)
|
|
26
|
+
}
|
|
27
|
+
return seen
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Run an async mapper over an array with a concurrency limit.
|
|
32
|
+
*
|
|
33
|
+
* @template T, R
|
|
34
|
+
* @param {T[]} arr
|
|
35
|
+
* @param {(item: T, i: number) => Promise<R>} mapper
|
|
36
|
+
* @param {number} [concurrency=8]
|
|
37
|
+
* @returns {Promise<R[]>}
|
|
38
|
+
*/
|
|
39
|
+
export async function mapWithConcurrency(arr, mapper, concurrency = 8) {
|
|
40
|
+
const results = new Array(arr.length)
|
|
41
|
+
let cursor = 0
|
|
42
|
+
const workers = new Array(Math.min(concurrency, arr.length)).fill(0).map(async () => {
|
|
43
|
+
while (cursor < arr.length) {
|
|
44
|
+
const i = cursor++
|
|
45
|
+
results[i] = await mapper(arr[i], i)
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
await Promise.all(workers)
|
|
49
|
+
return results
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default { resolveDeep, mapWithConcurrency }
|