@kyyinfinite/lumina 1.0.1 → 1.0.4
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/README.md +82 -0
- package/package.json +1 -1
- package/src/builders/index.js +1 -0
- package/src/builders/sticker.js +102 -0
- package/src/client/baileys-adapter.js +140 -0
- package/src/client/bot.js +6 -0
- package/src/client/connection.js +30 -14
- package/src/index.js +4 -1
- package/src/media/sticker.js +124 -0
package/README.md
CHANGED
|
@@ -360,6 +360,88 @@ Type-checked features:
|
|
|
360
360
|
- Enum-like catalogs (`MessageType`, `HeaderType`, `LayoutKind`, ...) are `Readonly`
|
|
361
361
|
- `Bot.raw` escape hatch typed as `WASocket`
|
|
362
362
|
|
|
363
|
+
|
|
364
|
+
---
|
|
365
|
+
|
|
366
|
+
## Baileys Compatibility
|
|
367
|
+
|
|
368
|
+
Lumina is not tied to a specific Baileys version or fork. When `new Bot(sock)` is called, Lumina automatically detects the available Baileys package in your project.
|
|
369
|
+
|
|
370
|
+
### Auto-Detect (default)
|
|
371
|
+
|
|
372
|
+
No configuration is required. Lumina tries the following packages in order and uses the first one that can be successfully imported:
|
|
373
|
+
|
|
374
|
+
```
|
|
375
|
+
@kyyinfinite/baileys
|
|
376
|
+
@whiskeysockets/baileys
|
|
377
|
+
baileys
|
|
378
|
+
@adiwajshing/baileys
|
|
379
|
+
@brunocgc/baileys
|
|
380
|
+
@open-wa/baileys
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
```js
|
|
384
|
+
// Lumina automatically detects @kyyinfinite/baileys (or any installed fork)
|
|
385
|
+
const bot = new Bot(sock)
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
---
|
|
389
|
+
|
|
390
|
+
### Explicit Package Name
|
|
391
|
+
|
|
392
|
+
If you're using a custom-named fork, specify it with `baileysPackage`:
|
|
393
|
+
|
|
394
|
+
```js
|
|
395
|
+
const bot = new Bot(sock, {
|
|
396
|
+
baileysPackage: '@namafork/baileys',
|
|
397
|
+
})
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
---
|
|
401
|
+
|
|
402
|
+
### Module Injection
|
|
403
|
+
|
|
404
|
+
The fastest option — zero dynamic imports. Simply inject the module directly:
|
|
405
|
+
|
|
406
|
+
```js
|
|
407
|
+
import * as baileys from '@kyyinfinite/baileys'
|
|
408
|
+
|
|
409
|
+
const bot = new Bot(sock, {
|
|
410
|
+
baileys,
|
|
411
|
+
})
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
This is ideal if you've already imported Baileys elsewhere and want to avoid duplicate imports.
|
|
415
|
+
|
|
416
|
+
---
|
|
417
|
+
|
|
418
|
+
### Socket Fallback
|
|
419
|
+
|
|
420
|
+
If no Baileys package is found, Lumina falls back to its internal socket-based implementation. Core features (text, buttons, carousel, stickers) continue to work. Media uploads require `sock.waUploadToServer` to be available.
|
|
421
|
+
|
|
422
|
+
---
|
|
423
|
+
|
|
424
|
+
### Debug: Check the Active Adapter
|
|
425
|
+
|
|
426
|
+
```js
|
|
427
|
+
const bot = new Bot(sock)
|
|
428
|
+
console.log(await bot.connection.adapterSource())
|
|
429
|
+
// → '@kyyinfinite/baileys'
|
|
430
|
+
// → '@whiskeysockets/baileys'
|
|
431
|
+
// → 'injected-module'
|
|
432
|
+
// → 'socket-fallback'
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
---
|
|
436
|
+
|
|
437
|
+
### Summary
|
|
438
|
+
|
|
439
|
+
| Option | When to use |
|
|
440
|
+
|---|---|
|
|
441
|
+
| _(not set)_ | Auto-detect. Recommended for most use cases. |
|
|
442
|
+
| `baileysPackage: 'name'` | For custom forks that are not included in the auto-detect list. |
|
|
443
|
+
| `baileys: module` | Manual module injection for the best performance. |
|
|
444
|
+
|
|
363
445
|
---
|
|
364
446
|
|
|
365
447
|
## API Reference
|
package/package.json
CHANGED
package/src/builders/index.js
CHANGED
|
@@ -3,4 +3,5 @@ export { ButtonV2Builder } from './button-v2.js'
|
|
|
3
3
|
export { CarouselBuilder } from './carousel.js'
|
|
4
4
|
export { CardBuilder } from './card.js'
|
|
5
5
|
export { AIRichBuilder } from './ai-rich.js'
|
|
6
|
+
export { StickerBuilder } from './sticker.js'
|
|
6
7
|
export { applyContentFields, readContentFields } from './base.js'
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { toSticker, addExif } from '../media/sticker.js'
|
|
2
|
+
import { fetchBuffer } from '../media/fetch.js'
|
|
3
|
+
import { sniffMime, mimeToCategory } from '../utils/mime.js'
|
|
4
|
+
import { coerceMediaSource } from '../utils/validator.js'
|
|
5
|
+
import { createInteractiveNodes } from '../proto/relay-nodes.js'
|
|
6
|
+
|
|
7
|
+
export class StickerBuilder {
|
|
8
|
+
constructor(conn) {
|
|
9
|
+
this.#conn = conn
|
|
10
|
+
this._source = null
|
|
11
|
+
this._type = null
|
|
12
|
+
this._packname = 'Lumina'
|
|
13
|
+
this._author = ''
|
|
14
|
+
this._categories = ['']
|
|
15
|
+
this._extra = {}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
#conn
|
|
19
|
+
|
|
20
|
+
source(input) {
|
|
21
|
+
this._source = input
|
|
22
|
+
return this
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
pack(packname, author = '') {
|
|
26
|
+
this._packname = packname
|
|
27
|
+
this._author = author
|
|
28
|
+
return this
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
author(author) {
|
|
32
|
+
this._author = author
|
|
33
|
+
return this
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
emoji(...emojis) {
|
|
37
|
+
this._categories = emojis.flat()
|
|
38
|
+
return this
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type(type) {
|
|
42
|
+
this._type = type
|
|
43
|
+
return this
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
extra(obj) {
|
|
47
|
+
Object.assign(this._extra, obj)
|
|
48
|
+
return this
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#metadata() {
|
|
52
|
+
return {
|
|
53
|
+
packname: this._packname,
|
|
54
|
+
author: this._author,
|
|
55
|
+
categories: this._categories,
|
|
56
|
+
extra: this._extra,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async #resolveBuffer() {
|
|
61
|
+
if (!this._source) throw new Error('sticker source is required — call .source(buffer|url)')
|
|
62
|
+
|
|
63
|
+
let buffer
|
|
64
|
+
|
|
65
|
+
if (Buffer.isBuffer(this._source)) {
|
|
66
|
+
buffer = this._source
|
|
67
|
+
} else if (typeof this._source === 'string') {
|
|
68
|
+
buffer = await fetchBuffer(this._source)
|
|
69
|
+
} else {
|
|
70
|
+
const { raw } = coerceMediaSource(this._source)
|
|
71
|
+
buffer = Buffer.isBuffer(raw) ? raw : await fetchBuffer(raw.url)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return buffer
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#detectType(buffer) {
|
|
78
|
+
if (this._type) return this._type
|
|
79
|
+
const mime = sniffMime(buffer)
|
|
80
|
+
const cat = mimeToCategory(mime)
|
|
81
|
+
if (cat === 'video') return 'video'
|
|
82
|
+
return 'image'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async build() {
|
|
86
|
+
const buffer = await this.#resolveBuffer()
|
|
87
|
+
const type = this.#detectType(buffer)
|
|
88
|
+
return toSticker(buffer, type, this.#metadata())
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async send(jid, opts = {}) {
|
|
92
|
+
const stickerBuffer = await this.build()
|
|
93
|
+
const msg = await this.#conn.generateMessage(jid, { sticker: stickerBuffer }, opts)
|
|
94
|
+
await this.#conn.relayMessage(msg.key.remoteJid, msg.message, {
|
|
95
|
+
messageId: msg.key.id,
|
|
96
|
+
...opts,
|
|
97
|
+
})
|
|
98
|
+
return msg
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export default StickerBuilder
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import { ConnectionError } from '../errors.js'
|
|
3
|
+
|
|
4
|
+
const KNOWN_PACKAGES = [
|
|
5
|
+
'@kyyinfinite/baileys',
|
|
6
|
+
'@whiskeysockets/baileys',
|
|
7
|
+
'baileys',
|
|
8
|
+
'@adiwajshing/baileys',
|
|
9
|
+
'@brunocgc/baileys',
|
|
10
|
+
'@open-wa/baileys',
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
const REQUIRED_FNS = [
|
|
14
|
+
'prepareWAMessageMedia',
|
|
15
|
+
'generateWAMessageFromContent',
|
|
16
|
+
'generatePollMessage',
|
|
17
|
+
'generateReactionMessage',
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
function extractFns(mod) {
|
|
21
|
+
const missing = REQUIRED_FNS.filter((fn) => typeof mod[fn] !== 'function')
|
|
22
|
+
if (missing.length > 0) return null
|
|
23
|
+
return {
|
|
24
|
+
prepareWAMessageMedia: mod.prepareWAMessageMedia,
|
|
25
|
+
generateWAMessageFromContent: mod.generateWAMessageFromContent,
|
|
26
|
+
generatePollMessage: mod.generatePollMessage,
|
|
27
|
+
generateReactionMessage: mod.generateReactionMessage,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeFallbackFns(socket) {
|
|
32
|
+
const generateId = () => crypto.randomBytes(8).toString('hex').toUpperCase()
|
|
33
|
+
|
|
34
|
+
const generateWAMessageFromContent = (jid, content, opts = {}) => ({
|
|
35
|
+
key: {
|
|
36
|
+
remoteJid: jid,
|
|
37
|
+
fromMe: true,
|
|
38
|
+
id: opts.messageId ?? generateId(),
|
|
39
|
+
participant: opts.participant,
|
|
40
|
+
},
|
|
41
|
+
message: content,
|
|
42
|
+
messageTimestamp: Math.floor(Date.now() / 1000),
|
|
43
|
+
status: 1,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const generatePollMessage = async (jid, opts) => {
|
|
47
|
+
const msg = await socket.sendMessage(jid, {
|
|
48
|
+
poll: {
|
|
49
|
+
name: opts.name,
|
|
50
|
+
values: opts.values,
|
|
51
|
+
selectableCount: opts.selectableCount ?? 1,
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
return msg
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const generateReactionMessage = (jid, opts) =>
|
|
58
|
+
generateWAMessageFromContent(jid, {
|
|
59
|
+
reactionMessage: { key: opts.key, text: opts.text },
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const prepareWAMessageMedia = async (media, opts = {}) => {
|
|
63
|
+
if (!socket.waUploadToServer) {
|
|
64
|
+
throw new ConnectionError(
|
|
65
|
+
'Cannot upload media: no baileys package found and socket.waUploadToServer is unavailable',
|
|
66
|
+
{ code: 'LUMINA_ADAPTER_NO_UPLOAD' },
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
const [type, source] = Object.entries(media)[0]
|
|
70
|
+
const uploaded = await socket.waUploadToServer(
|
|
71
|
+
Buffer.isBuffer(source) ? source : source,
|
|
72
|
+
{ mediaType: type, jid: opts.jid },
|
|
73
|
+
)
|
|
74
|
+
return { [type]: { url: uploaded.url, ...uploaded } }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { generateWAMessageFromContent, generatePollMessage, generateReactionMessage, prepareWAMessageMedia }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class BaileysAdapter {
|
|
81
|
+
#fns = null
|
|
82
|
+
#source = 'unknown'
|
|
83
|
+
|
|
84
|
+
static async resolve(socket, opts = {}) {
|
|
85
|
+
const adapter = new BaileysAdapter()
|
|
86
|
+
|
|
87
|
+
if (opts.baileys && typeof opts.baileys === 'object') {
|
|
88
|
+
const fns = extractFns(opts.baileys)
|
|
89
|
+
if (fns) {
|
|
90
|
+
adapter.#fns = fns
|
|
91
|
+
adapter.#source = 'injected-module'
|
|
92
|
+
return adapter
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (typeof opts.baileysPackage === 'string') {
|
|
97
|
+
try {
|
|
98
|
+
const mod = await import(opts.baileysPackage)
|
|
99
|
+
const fns = extractFns(mod)
|
|
100
|
+
if (fns) {
|
|
101
|
+
adapter.#fns = fns
|
|
102
|
+
adapter.#source = opts.baileysPackage
|
|
103
|
+
return adapter
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
throw new ConnectionError(
|
|
107
|
+
`baileysPackage "${opts.baileysPackage}" could not be imported or is missing required functions`,
|
|
108
|
+
{ code: 'LUMINA_ADAPTER_PACKAGE_NOT_FOUND' },
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const pkg of KNOWN_PACKAGES) {
|
|
114
|
+
try {
|
|
115
|
+
const mod = await import(pkg)
|
|
116
|
+
const fns = extractFns(mod)
|
|
117
|
+
if (fns) {
|
|
118
|
+
adapter.#fns = fns
|
|
119
|
+
adapter.#source = pkg
|
|
120
|
+
return adapter
|
|
121
|
+
}
|
|
122
|
+
} catch {}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
adapter.#fns = makeFallbackFns(socket)
|
|
126
|
+
adapter.#source = 'socket-fallback'
|
|
127
|
+
return adapter
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
get source() {
|
|
131
|
+
return this.#source
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
get prepareWAMessageMedia() { return this.#fns.prepareWAMessageMedia }
|
|
135
|
+
get generateWAMessageFromContent() { return this.#fns.generateWAMessageFromContent }
|
|
136
|
+
get generatePollMessage() { return this.#fns.generatePollMessage }
|
|
137
|
+
get generateReactionMessage() { return this.#fns.generateReactionMessage }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export default BaileysAdapter
|
package/src/client/bot.js
CHANGED
|
@@ -6,6 +6,7 @@ import { ButtonBuilder } from '../builders/button.js'
|
|
|
6
6
|
import { ButtonV2Builder } from '../builders/button-v2.js'
|
|
7
7
|
import { CarouselBuilder } from '../builders/carousel.js'
|
|
8
8
|
import { AIRichBuilder } from '../builders/ai-rich.js'
|
|
9
|
+
import { StickerBuilder } from '../builders/sticker.js'
|
|
9
10
|
import { extractInlineEntities, tokenizeCode, toTableMetadata } from '../parsers/index.js'
|
|
10
11
|
import { createLogger } from '../utils/logger.js'
|
|
11
12
|
|
|
@@ -50,6 +51,11 @@ export class Bot {
|
|
|
50
51
|
buttonV2() { return new ButtonV2Builder(this.connection) }
|
|
51
52
|
carousel() { return new CarouselBuilder(this.connection, this.proto, this.media) }
|
|
52
53
|
ai() { return new AIRichBuilder(this.connection, this.proto, this.media) }
|
|
54
|
+
sticker() { return new StickerBuilder(this.connection) }
|
|
55
|
+
|
|
56
|
+
async sendSticker(jid, source, opts = {}) {
|
|
57
|
+
return this.sticker().source(source).pack(opts.packname, opts.author).emoji(...(opts.categories ?? [''])).send(jid, opts)
|
|
58
|
+
}
|
|
53
59
|
|
|
54
60
|
on(event, handler) { return this.connection.on(event, handler) }
|
|
55
61
|
once(event, handler) { return this.connection.once(event, handler) }
|
package/src/client/connection.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { ConnectionError } from '../errors.js'
|
|
2
|
+
import { BaileysAdapter } from './baileys-adapter.js'
|
|
2
3
|
|
|
3
4
|
export class Connection {
|
|
5
|
+
#socket = null
|
|
6
|
+
#adapter = null
|
|
7
|
+
|
|
4
8
|
constructor(socket, opts = {}) {
|
|
5
9
|
if (!socket) throw new ConnectionError('socket is required', { code: 'LUMINA_CONNECTION_NO_SOCKET' })
|
|
6
|
-
this.#socket = socket
|
|
7
|
-
this.uploadJid = opts.uploadJid
|
|
8
|
-
this.logger = opts.logger
|
|
9
10
|
|
|
10
11
|
const required = ['relayMessage', 'ev']
|
|
11
12
|
for (const m of required) {
|
|
@@ -15,34 +16,46 @@ export class Connection {
|
|
|
15
16
|
})
|
|
16
17
|
}
|
|
17
18
|
}
|
|
19
|
+
|
|
20
|
+
this.#socket = socket
|
|
21
|
+
this.uploadJid = opts.uploadJid
|
|
22
|
+
this.logger = opts.logger
|
|
23
|
+
this.#adapter = BaileysAdapter.resolve(socket, opts)
|
|
18
24
|
}
|
|
19
25
|
|
|
20
|
-
#
|
|
26
|
+
async #fn() {
|
|
27
|
+
if (this.#adapter instanceof Promise) this.#adapter = await this.#adapter
|
|
28
|
+
return this.#adapter
|
|
29
|
+
}
|
|
21
30
|
|
|
22
31
|
async uploadMedia(media, opts = {}) {
|
|
23
|
-
const
|
|
32
|
+
const adapter = await this.#fn()
|
|
24
33
|
const jid = opts.jid ?? this.uploadJid
|
|
25
34
|
if (!jid) {
|
|
26
35
|
throw new ConnectionError('uploadJid must be set (via constructor or per-call)', {
|
|
27
36
|
code: 'LUMINA_CONNECTION_NO_UPLOAD_JID',
|
|
28
37
|
})
|
|
29
38
|
}
|
|
30
|
-
return prepareWAMessageMedia(media, {
|
|
39
|
+
return adapter.prepareWAMessageMedia(media, {
|
|
40
|
+
upload: this.#socket.waUploadToServer,
|
|
41
|
+
jid,
|
|
42
|
+
...opts,
|
|
43
|
+
})
|
|
31
44
|
}
|
|
32
45
|
|
|
33
46
|
async generateMessage(jid, content, opts = {}) {
|
|
34
|
-
const
|
|
35
|
-
return generateWAMessageFromContent(jid, content, opts)
|
|
47
|
+
const adapter = await this.#fn()
|
|
48
|
+
return adapter.generateWAMessageFromContent(jid, content, opts)
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
async generatePoll(jid, opts) {
|
|
39
|
-
const
|
|
40
|
-
return generatePollMessage(jid, opts)
|
|
52
|
+
const adapter = await this.#fn()
|
|
53
|
+
return adapter.generatePollMessage(jid, opts)
|
|
41
54
|
}
|
|
42
55
|
|
|
43
56
|
async generateReaction(jid, opts) {
|
|
44
|
-
const
|
|
45
|
-
return generateReactionMessage(jid, opts)
|
|
57
|
+
const adapter = await this.#fn()
|
|
58
|
+
return adapter.generateReactionMessage(jid, opts)
|
|
46
59
|
}
|
|
47
60
|
|
|
48
61
|
async relayMessage(jid, message, opts = {}) {
|
|
@@ -65,8 +78,11 @@ export class Connection {
|
|
|
65
78
|
this.#socket.ev.off(event, handler)
|
|
66
79
|
}
|
|
67
80
|
|
|
68
|
-
get raw() {
|
|
69
|
-
|
|
81
|
+
get raw() { return this.#socket }
|
|
82
|
+
|
|
83
|
+
async adapterSource() {
|
|
84
|
+
const adapter = await this.#fn()
|
|
85
|
+
return adapter.source
|
|
70
86
|
}
|
|
71
87
|
}
|
|
72
88
|
|
package/src/index.js
CHANGED
|
@@ -5,6 +5,8 @@ import { ButtonV2Builder } from './builders/button-v2.js'
|
|
|
5
5
|
import { CarouselBuilder } from './builders/carousel.js'
|
|
6
6
|
import { CardBuilder } from './builders/card.js'
|
|
7
7
|
import { AIRichBuilder } from './builders/ai-rich.js'
|
|
8
|
+
import { StickerBuilder } from './builders/sticker.js'
|
|
9
|
+
import { toSticker, addExif } from './media/sticker.js'
|
|
8
10
|
import { MediaService } from './services/media-service.js'
|
|
9
11
|
import { ProtoService } from './services/proto-service.js'
|
|
10
12
|
import { MessageService } from './services/message-service.js'
|
|
@@ -32,7 +34,8 @@ export const VERSION = '1.0.0'
|
|
|
32
34
|
|
|
33
35
|
export {
|
|
34
36
|
Bot, Connection,
|
|
35
|
-
ButtonBuilder, ButtonV2Builder, CarouselBuilder, CardBuilder, AIRichBuilder,
|
|
37
|
+
ButtonBuilder, ButtonV2Builder, CarouselBuilder, CardBuilder, AIRichBuilder, StickerBuilder,
|
|
38
|
+
toSticker, addExif,
|
|
36
39
|
MediaService, ProtoService, MessageService,
|
|
37
40
|
extractInlineEntities, tokenizeCode, toTableMetadata,
|
|
38
41
|
ProtoUpdater, transformToESM, applyKnownFixes,
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { MediaError } from '../errors.js'
|
|
6
|
+
|
|
7
|
+
function tmpPath(ext) {
|
|
8
|
+
return path.join(tmpdir(), `${crypto.randomBytes(6).readUIntLE(0, 6).toString(36)}.${ext}`)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function cleanup(...files) {
|
|
12
|
+
for (const f of files) {
|
|
13
|
+
try { fs.unlinkSync(f) } catch {}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function loadFfmpeg() {
|
|
18
|
+
try {
|
|
19
|
+
const { default: ffmpeg } = await import('fluent-ffmpeg')
|
|
20
|
+
return ffmpeg
|
|
21
|
+
} catch {
|
|
22
|
+
throw new MediaError('fluent-ffmpeg is not installed. Run: npm i fluent-ffmpeg', {
|
|
23
|
+
code: 'LUMINA_STICKER_NO_FFMPEG',
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function loadWebp() {
|
|
29
|
+
try {
|
|
30
|
+
const { default: webp } = await import('node-webpmux')
|
|
31
|
+
return webp
|
|
32
|
+
} catch {
|
|
33
|
+
throw new MediaError('node-webpmux is not installed. Run: npm i node-webpmux', {
|
|
34
|
+
code: 'LUMINA_STICKER_NO_WEBPMUX',
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const FFMPEG_IMAGE_OPTS = [
|
|
40
|
+
'-vcodec', 'libwebp',
|
|
41
|
+
'-vf', "scale='min(320,iw)':min'(320,ih)':force_original_aspect_ratio=decrease,fps=15,pad=320:320:-1:-1:color=white@0.0,split[a][b];[a]palettegen=reserve_transparent=on:transparency_color=ffffff[p];[b][p]paletteuse",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
const FFMPEG_VIDEO_OPTS = [
|
|
45
|
+
'-vcodec', 'libwebp',
|
|
46
|
+
'-vf', "scale='min(320,iw)':min'(320,ih)':force_original_aspect_ratio=decrease,fps=15,pad=320:320:-1:-1:color=white@0.0,split[a][b];[a]palettegen=reserve_transparent=on:transparency_color=ffffff[p];[b][p]paletteuse",
|
|
47
|
+
'-loop', '0',
|
|
48
|
+
'-ss', '00:00:00',
|
|
49
|
+
'-t', '00:00:05',
|
|
50
|
+
'-preset', 'default',
|
|
51
|
+
'-an',
|
|
52
|
+
'-vsync', '0',
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
async function convertToWebp(buffer, type) {
|
|
56
|
+
const ffmpeg = await loadFfmpeg()
|
|
57
|
+
const inExt = type === 'video' ? 'mp4' : 'jpg'
|
|
58
|
+
const inFile = tmpPath(inExt)
|
|
59
|
+
const outFile = tmpPath('webp')
|
|
60
|
+
|
|
61
|
+
fs.writeFileSync(inFile, buffer)
|
|
62
|
+
|
|
63
|
+
await new Promise((resolve, reject) => {
|
|
64
|
+
ffmpeg(inFile)
|
|
65
|
+
.on('error', reject)
|
|
66
|
+
.on('end', () => resolve())
|
|
67
|
+
.addOutputOptions(type === 'video' ? FFMPEG_VIDEO_OPTS : FFMPEG_IMAGE_OPTS)
|
|
68
|
+
.toFormat('webp')
|
|
69
|
+
.save(outFile)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const result = fs.readFileSync(outFile)
|
|
73
|
+
cleanup(inFile, outFile)
|
|
74
|
+
return result
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function buildExif(metadata) {
|
|
78
|
+
const json = {
|
|
79
|
+
'sticker-pack-id': crypto.randomBytes(32).toString('hex'),
|
|
80
|
+
'sticker-pack-name': metadata.packname ?? 'Lumina',
|
|
81
|
+
'sticker-pack-publisher': metadata.author ?? '',
|
|
82
|
+
'emojis': metadata.categories ?? [''],
|
|
83
|
+
...metadata.extra,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const attr = Buffer.from([
|
|
87
|
+
0x49, 0x49, 0x2A, 0x00, 0x08, 0x00, 0x00, 0x00,
|
|
88
|
+
0x01, 0x00, 0x41, 0x57, 0x07, 0x00, 0x00, 0x00,
|
|
89
|
+
0x00, 0x00, 0x16, 0x00, 0x00, 0x00,
|
|
90
|
+
])
|
|
91
|
+
const jsonBuf = Buffer.from(JSON.stringify(json), 'utf8')
|
|
92
|
+
const exif = Buffer.concat([attr, jsonBuf])
|
|
93
|
+
exif.writeUIntLE(jsonBuf.length, 14, 4)
|
|
94
|
+
return exif
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function injectExif(webpBuffer, metadata) {
|
|
98
|
+
const webp = await loadWebp()
|
|
99
|
+
const img = new webp.Image()
|
|
100
|
+
await img.load(webpBuffer)
|
|
101
|
+
img.exif = buildExif(metadata)
|
|
102
|
+
return img.save(null)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function toSticker(buffer, type = 'image', metadata = {}) {
|
|
106
|
+
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
|
|
107
|
+
throw new MediaError('toSticker() requires a non-empty Buffer', { code: 'LUMINA_STICKER_EMPTY' })
|
|
108
|
+
}
|
|
109
|
+
if (!['image', 'video'].includes(type)) {
|
|
110
|
+
throw new MediaError(`type must be image or video, got: ${type}`, { code: 'LUMINA_STICKER_BAD_TYPE' })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const webp = await convertToWebp(buffer, type)
|
|
114
|
+
return injectExif(webp, metadata)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function addExif(webpBuffer, metadata = {}) {
|
|
118
|
+
if (!Buffer.isBuffer(webpBuffer) || webpBuffer.length === 0) {
|
|
119
|
+
throw new MediaError('addExif() requires a non-empty Buffer', { code: 'LUMINA_STICKER_EMPTY' })
|
|
120
|
+
}
|
|
121
|
+
return injectExif(webpBuffer, metadata)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export default { toSticker, addExif }
|