@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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +629 -0
  3. package/examples/ai-rich.js +84 -0
  4. package/examples/button.js +57 -0
  5. package/examples/carousel.js +51 -0
  6. package/examples/interactive.js +102 -0
  7. package/examples/media.js +66 -0
  8. package/examples/simple-bot.js +56 -0
  9. package/package.json +86 -0
  10. package/src/builders/ai-rich.js +644 -0
  11. package/src/builders/base.js +109 -0
  12. package/src/builders/button-v2.js +159 -0
  13. package/src/builders/button.js +398 -0
  14. package/src/builders/card.js +168 -0
  15. package/src/builders/carousel.js +122 -0
  16. package/src/builders/index.d.ts +1 -0
  17. package/src/builders/index.js +13 -0
  18. package/src/client/bot.js +192 -0
  19. package/src/client/connection.js +180 -0
  20. package/src/errors.js +88 -0
  21. package/src/index.d.ts +458 -0
  22. package/src/index.js +152 -0
  23. package/src/media/fetch.js +67 -0
  24. package/src/media/image.js +86 -0
  25. package/src/media/index.d.ts +1 -0
  26. package/src/media/index.js +12 -0
  27. package/src/media/resolver.js +115 -0
  28. package/src/media/uploader.js +65 -0
  29. package/src/media/video.js +195 -0
  30. package/src/parsers/code-tokenizer-keywords.js +128 -0
  31. package/src/parsers/code-tokenizer.js +191 -0
  32. package/src/parsers/index.d.ts +1 -0
  33. package/src/parsers/index.js +11 -0
  34. package/src/parsers/inline-entity.js +231 -0
  35. package/src/parsers/table-metadata.js +69 -0
  36. package/src/proto/enums.js +170 -0
  37. package/src/proto/index.d.ts +1 -0
  38. package/src/proto/index.js +13 -0
  39. package/src/proto/layouts.js +89 -0
  40. package/src/proto/primitives.js +181 -0
  41. package/src/proto/relay-nodes.js +55 -0
  42. package/src/proto/rich-response.js +144 -0
  43. package/src/proto/updater.js +318 -0
  44. package/src/services/index.d.ts +1 -0
  45. package/src/services/index.js +10 -0
  46. package/src/services/media-service.js +184 -0
  47. package/src/services/message-service.js +288 -0
  48. package/src/services/proto-service.js +90 -0
  49. package/src/utils/id.js +42 -0
  50. package/src/utils/logger.js +65 -0
  51. package/src/utils/mime.js +104 -0
  52. package/src/utils/promise.js +52 -0
  53. 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
@@ -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 }