@kyyinfinite/lumina 1.0.1 → 1.0.2
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/package.json +1 -1
- package/src/builders/index.js +1 -0
- package/src/builders/sticker.js +102 -0
- package/src/client/bot.js +6 -0
- package/src/index.js +4 -1
- package/src/media/sticker.js +124 -0
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
|
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/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 }
|