@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,318 @@
1
+ /**
2
+ * @file updater.js
3
+ * @module lumina/proto/updater
4
+ *
5
+ * ProtoUpdater — automatic WAProto maintenance utility.
6
+ *
7
+ * WhatsApp constantly ships new protobuf definitions to its Web client, and
8
+ * the community `@whiskeysockets/baileys` package vendors a snapshot of those
9
+ * definitions as `WAProto/`. Lumina ships a small utility that:
10
+ *
11
+ * 1. Backs up the current WAProto tree before any mutation.
12
+ * 2. Transforms the generated CommonJS files into ESM (`require` → `import`,
13
+ * `module.exports` → `export`).
14
+ * 3. Fixes known upstream issues (e.g. the `HistorySyncType` enum that the
15
+ * `pbjs` generator sometimes emits with the wrong numeric tag).
16
+ * 4. Validates the result by `import()`-ing each module file.
17
+ * 5. Rolls back automatically on validation failure (configurable).
18
+ *
19
+ * The updater is opt-in: it never runs at import time. Users invoke it from
20
+ * the CLI or programmatically when they want to refresh the proto bundle.
21
+ */
22
+
23
+ import { promises as fs } from 'node:fs'
24
+ import path from 'node:path'
25
+ import { fileURLToPath, pathToFileURL } from 'node:url'
26
+
27
+ import { ProtocolError } from '../errors.js'
28
+ import { sha256 } from '../utils/id.js'
29
+ import { createLogger } from '../utils/logger.js'
30
+
31
+ /** @typedef {import('../utils/logger.js').Logger} Logger */
32
+
33
+ /**
34
+ * Known WAProto fixes. Each entry: { file: RegExp, apply: (content: string) => string }.
35
+ * Keeping these in one place makes them trivial to audit & extend.
36
+ */
37
+ const KNOWN_FIXES = [
38
+ {
39
+ name: 'history-sync-type-enum',
40
+ file: /HistorySync\.js$/,
41
+ apply: (c) =>
42
+ // Ensure `HistorySyncType.INITIAL_BOOTSTRAP = 0` exists — some pbjs runs
43
+ // emit it as `= 1` which breaks downstream comparison logic.
44
+ c.replace(
45
+ /(HistorySyncType\s*=\s*\{)[\s\S]*?\}/,
46
+ (m) =>
47
+ m
48
+ .replace(/INITIAL_BOOTSTRAP\s*:\s*[1-9]\d*/, 'INITIAL_BOOTSTRAP: 0')
49
+ .replace(/INITIAL_BOOTSTRAP\s*=\s*[1-9]\d*/, 'INITIAL_BOOTSTRAP = 0'),
50
+ ),
51
+ },
52
+ {
53
+ name: 'recent-messages-enum',
54
+ file: /HistorySync\.js$/,
55
+ apply: (c) =>
56
+ c.replace(
57
+ /(RecentMessagesWeightInheritance\s*=\s*\{)[\s\S]*?\}/,
58
+ (m) => m.replace(/CHRONOLOGICAL\s*:\s*0/, 'CHRONOLOGICAL: 1'),
59
+ ),
60
+ },
61
+ ]
62
+
63
+ /** Pattern for CommonJS `require('...')` — used by CJS→ESM transform. */
64
+ const RE_REQUIRE = /require\(\s*(['"])([^'"]+)\1\s*\)/g
65
+
66
+ /** Pattern for `module.exports = ...` / `exports.x = ...`. */
67
+ const RE_MODULE_EXPORTS_ASSIGN = /module\.exports\s*=\s*/g
68
+ const RE_EXPORTS_DOT = /exports\.(\w+)\s*=\s*/g
69
+
70
+ /** Pattern for `const x = require('...')` — convert to `import`. */
71
+ const RE_CONST_REQUIRE =
72
+ /(?:const|let|var)\s+(\w+)\s*=\s*require\(\s*(['"])([^'"]+)\2\s*\)/g
73
+
74
+ /** Pattern for destructured `const { a, b } = require('...')`. */
75
+ const RE_DESTRUCT_REQUIRE =
76
+ /(?:const|let|var)\s*\{([^}]+)\}\s*=\s*require\(\s*(['"])([^'"]+)\2\s*\)/g
77
+
78
+ /**
79
+ * Convert a CommonJS module's source to ESM.
80
+ *
81
+ * @param {string} src Original CommonJS source.
82
+ * @returns {string} ESM source.
83
+ */
84
+ export function transformToESM(src) {
85
+ let out = src
86
+ // 1. const X = require('mod') → import X from 'mod'
87
+ out = out.replace(RE_CONST_REQUIRE, (_, name, _q, mod) => `import ${name} from '${mod}'`)
88
+ // 2. const { a, b } = require('mod') → import { a, b } from 'mod'
89
+ out = out.replace(RE_DESTRUCT_REQUIRE, (_, names, _q, mod) => {
90
+ const cleaned = names
91
+ .split(',')
92
+ .map((n) => n.trim())
93
+ .filter(Boolean)
94
+ .map((n) => (n.includes(':') ? n : n))
95
+ .join(', ')
96
+ return `import { ${cleaned} } from '${mod}'`
97
+ })
98
+ // 3. Bare require('mod') → import 'mod'
99
+ out = out.replace(RE_REQUIRE, (_, _q, mod) => `import '${mod}'`)
100
+ // 4. module.exports = X → export default X
101
+ out = out.replace(RE_MODULE_EXPORTS_ASSIGN, 'export default ')
102
+ // 5. exports.x = ... → export const x = ...
103
+ out = out.replace(RE_EXPORTS_DOT, (_, name) => `export const ${name} = `)
104
+ // 6. 'use strict' is implicit in ESM — drop it.
105
+ out = out.replace(/^['"]use strict['"];?\s*\n/m, '')
106
+ return out
107
+ }
108
+
109
+ /**
110
+ * Apply every known WAProto fix to a single file's content.
111
+ *
112
+ * @param {string} filename Absolute path of the file being patched.
113
+ * @param {string} content Current file content.
114
+ * @returns {{ content: string, applied: string[] }} New content + list of fix names that fired.
115
+ */
116
+ export function applyKnownFixes(filename, content) {
117
+ const applied = []
118
+ let next = content
119
+ for (const fix of KNOWN_FIXES) {
120
+ if (fix.file.test(filename)) {
121
+ const before = next
122
+ next = fix.apply(next)
123
+ if (next !== before) applied.push(fix.name)
124
+ }
125
+ }
126
+ return { content: next, applied }
127
+ }
128
+
129
+ /**
130
+ * ProtoUpdater — high-level orchestration.
131
+ */
132
+ export class ProtoUpdater {
133
+ /** @param {object} [opts] */
134
+ constructor(opts = {}) {
135
+ this.protoPath = path.resolve(opts.protoPath ?? this.#defaultProtoPath())
136
+ this.backupDir = path.resolve(opts.backupDir ?? path.join(this.protoPath, '..', 'WAProto.backups'))
137
+ this.logger = (opts.logger ?? createLogger({ level: 'warn' })).child('proto-updater')
138
+ /** @type {Array<{ id: string, timestamp: number, path: string, hash: string }>} */
139
+ this.history = []
140
+ }
141
+
142
+ /** Resolve the default WAProto directory inside the installed Baileys package. */
143
+ #defaultProtoPath() {
144
+ try {
145
+ const baileysPkg = require.resolve('@whiskeysockets/baileys/package.json', {
146
+ paths: [process.cwd()],
147
+ })
148
+ return path.join(path.dirname(baileysPkg), 'WAProto')
149
+ } catch {
150
+ // Fallback: assume CWD/WAProto
151
+ return path.join(process.cwd(), 'WAProto')
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Walk a directory recursively, returning every `.js` file path.
157
+ *
158
+ * @param {string} dir
159
+ * @returns {Promise<string[]>}
160
+ */
161
+ async #listJsFiles(dir) {
162
+ const entries = await fs.readdir(dir, { withFileTypes: true })
163
+ const out = []
164
+ for (const e of entries) {
165
+ const full = path.join(dir, e.name)
166
+ if (e.isDirectory()) out.push(...(await this.#listJsFiles(full)))
167
+ else if (e.isFile() && e.name.endsWith('.js')) out.push(full)
168
+ }
169
+ return out
170
+ }
171
+
172
+ /**
173
+ * Create a timestamped backup of the current WAProto tree.
174
+ *
175
+ * @returns {Promise<{ id: string, timestamp: number, path: string, hash: string }>}
176
+ */
177
+ async backup() {
178
+ await fs.mkdir(this.backupDir, { recursive: true })
179
+ const timestamp = Date.now()
180
+ const id = `backup-${timestamp}`
181
+ const dest = path.join(this.backupDir, id)
182
+ await fs.cp(this.protoPath, dest, { recursive: true })
183
+
184
+ const hash = await this.#hashTree(dest)
185
+ const record = { id, timestamp, path: dest, hash }
186
+ this.history.push(record)
187
+ this.logger.info(`backup created: ${id} (sha256=${hash.slice(0, 12)}…)`)
188
+ return record
189
+ }
190
+
191
+ /**
192
+ * Recursively hash every `.js` file in a directory. Used to detect
193
+ * whether a transform actually changed anything.
194
+ *
195
+ * @param {string} dir
196
+ * @returns {Promise<string>}
197
+ */
198
+ async #hashTree(dir) {
199
+ const files = (await this.#listJsFiles(dir)).sort()
200
+ const hashes = await Promise.all(
201
+ files.map(async (f) => {
202
+ const c = await fs.readFile(f, 'utf8')
203
+ return `${path.relative(dir, f)}:${sha256(c)}`
204
+ }),
205
+ )
206
+ return sha256(hashes.join('\n'))
207
+ }
208
+
209
+ /**
210
+ * Restore a specific backup by id.
211
+ *
212
+ * @param {string} id
213
+ */
214
+ async restore(id) {
215
+ const record = this.history.find((h) => h.id === id)
216
+ if (!record) {
217
+ throw new ProtocolError(`backup not found: ${id}`, { code: 'LUMINA_PROTOCOL_BACKUP_NOT_FOUND' })
218
+ }
219
+ await fs.rm(this.protoPath, { recursive: true, force: true })
220
+ await fs.cp(record.path, this.protoPath, { recursive: true })
221
+ this.logger.warn(`restored backup: ${id}`)
222
+ }
223
+
224
+ /**
225
+ * Rollback to the most recent successful backup.
226
+ */
227
+ async rollback() {
228
+ if (this.history.length === 0) {
229
+ throw new ProtocolError('no backup available for rollback', {
230
+ code: 'LUMINA_PROTOCOL_NO_BACKUP',
231
+ })
232
+ }
233
+ const last = this.history[this.history.length - 1]
234
+ await this.restore(last.id)
235
+ }
236
+
237
+ /**
238
+ * Validate the current WAProto tree by importing every `.js` file.
239
+ *
240
+ * @returns {Promise<{ ok: boolean, errors: Array<{ file: string, message: string }> }>}
241
+ */
242
+ async validate() {
243
+ const files = await this.#listJsFiles(this.protoPath)
244
+ const errors = []
245
+ await Promise.all(
246
+ files.map(async (f) => {
247
+ try {
248
+ await import(pathToFileURL(f).href)
249
+ } catch (err) {
250
+ errors.push({ file: path.relative(this.protoPath, f), message: err?.message ?? String(err) })
251
+ }
252
+ }),
253
+ )
254
+ return { ok: errors.length === 0, errors }
255
+ }
256
+
257
+ /**
258
+ * Run the full update pipeline: backup → transform → fix → validate → commit/rollback.
259
+ *
260
+ * @param {object} [opts]
261
+ * @param {boolean} [opts.autoRollback=true] Rollback automatically on validation failure.
262
+ * @param {boolean} [opts.dryRun=false] Don't write to disk — return a diff summary.
263
+ * @returns {Promise<{ success: boolean, fromHash: string, toHash: string, backupId: string|null, appliedFixes: string[], errors: any[] }>}
264
+ */
265
+ async update(opts = {}) {
266
+ const autoRollback = opts.autoRollback ?? true
267
+ const dryRun = opts.dryRun ?? false
268
+
269
+ const fromHash = await this.#hashTree(this.protoPath)
270
+ const files = await this.#listJsFiles(this.protoPath)
271
+
272
+ let backupId = null
273
+ if (!dryRun) {
274
+ const backup = await this.backup()
275
+ backupId = backup.id
276
+ }
277
+
278
+ const appliedFixes = new Set()
279
+ const errors = []
280
+
281
+ for (const file of files) {
282
+ try {
283
+ const original = await fs.readFile(file, 'utf8')
284
+ const transformed = transformToESM(original)
285
+ const fixed = applyKnownFixes(file, transformed)
286
+ fixed.applied.forEach((n) => appliedFixes.add(n))
287
+
288
+ if (!dryRun && fixed.content !== original) {
289
+ await fs.writeFile(file, fixed.content, 'utf8')
290
+ }
291
+ } catch (err) {
292
+ errors.push({ file: path.relative(this.protoPath, file), message: err?.message ?? String(err) })
293
+ }
294
+ }
295
+
296
+ const toHash = dryRun ? fromHash : await this.#hashTree(this.protoPath)
297
+
298
+ if (dryRun) {
299
+ this.logger.info(`dry-run complete: ${appliedFixes.size} fixes would apply, ${errors.length} errors`)
300
+ return { success: errors.length === 0, fromHash, toHash: fromHash, backupId: null, appliedFixes: [...appliedFixes], errors }
301
+ }
302
+
303
+ const validation = await this.validate()
304
+ if (!validation.ok) {
305
+ errors.push(...validation.errors)
306
+ if (autoRollback && backupId) {
307
+ this.logger.warn(`validation failed (${validation.errors.length} errors) — rolling back`)
308
+ await this.rollback()
309
+ return { success: false, fromHash, toHash: fromHash, backupId, appliedFixes: [...appliedFixes], errors }
310
+ }
311
+ }
312
+
313
+ this.logger.info(`update complete: ${appliedFixes.size} fixes applied, hash ${fromHash.slice(0, 8)} → ${toHash.slice(0, 8)}`)
314
+ return { success: errors.length === 0, fromHash, toHash, backupId, appliedFixes: [...appliedFixes], errors }
315
+ }
316
+ }
317
+
318
+ export default ProtoUpdater
@@ -0,0 +1 @@
1
+ export * from '../index.d.ts'
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @file services/index.js
3
+ * @module lumina/services
4
+ *
5
+ * Barrel re-export for the services layer.
6
+ */
7
+
8
+ export { MediaService } from './media-service.js'
9
+ export { ProtoService } from './proto-service.js'
10
+ export { MessageService } from './message-service.js'
@@ -0,0 +1,184 @@
1
+ /**
2
+ * @file media-service.js
3
+ * @module lumina/services/media-service
4
+ *
5
+ * High-level orchestrator for every media operation. Composes the lower-level
6
+ * `media/*` modules with a Connection instance and (optionally) a small LRU
7
+ * cache for repeated URL fetches.
8
+ */
9
+
10
+ import { fetchBuffer } from '../media/fetch.js'
11
+ import { resize, thumbnail } from '../media/image.js'
12
+ import { getMp4Duration, extractThumbnail } from '../media/video.js'
13
+ import { uploadToWhatsApp } from '../media/uploader.js'
14
+ import { resolveMedia } from '../media/resolver.js'
15
+ import { sniffMime, mimeToCategory } from '../utils/mime.js'
16
+
17
+ /** @typedef {import('../client/connection.js').Connection} Connection */
18
+ /** @typedef {import('../utils/logger.js').Logger} Logger */
19
+
20
+ const LRU_MAX = 128
21
+
22
+ /**
23
+ * @typedef {object} MediaServiceOptions
24
+ * @property {boolean} [cache=true] Enable URL→buffer LRU cache.
25
+ * @property {number} [cacheMax=128] Max cache entries.
26
+ * @property {Logger} [logger]
27
+ * @property {string} [uploadJid]
28
+ */
29
+
30
+ export class MediaService {
31
+ /**
32
+ * @param {Connection} conn
33
+ * @param {MediaServiceOptions} [opts]
34
+ */
35
+ constructor(conn, opts = {}) {
36
+ if (!conn) throw new Error('Connection is required for MediaService')
37
+ this.conn = conn
38
+ this.logger = opts.logger?.child('media')
39
+ this.uploadJid = opts.uploadJid
40
+ this.#cacheEnabled = opts.cache ?? true
41
+ this.#cacheMax = opts.cacheMax ?? LRU_MAX
42
+ /** @type {Map<string, { buf: Buffer, ts: number }>} */
43
+ this.#cache = new Map()
44
+ }
45
+
46
+ /** @type {boolean} */
47
+ #cacheEnabled
48
+ /** @type {number} */
49
+ #cacheMax
50
+ /** @type {Map<string, { buf: Buffer, ts: number }>} */
51
+ #cache
52
+
53
+ /**
54
+ * @param {string} url
55
+ * @returns {Buffer | undefined}
56
+ */
57
+ #cacheGet(url) {
58
+ if (!this.#cacheEnabled) return undefined
59
+ const entry = this.#cache.get(url)
60
+ if (!entry) return undefined
61
+ // Refresh LRU order.
62
+ this.#cache.delete(url)
63
+ this.#cache.set(url, entry)
64
+ return entry.buf
65
+ }
66
+
67
+ /**
68
+ * @param {string} url
69
+ * @param {Buffer} buf
70
+ */
71
+ #cacheSet(url, buf) {
72
+ if (!this.#cacheEnabled) return
73
+ if (this.#cache.size >= this.#cacheMax) {
74
+ const oldest = this.#cache.keys().next().value
75
+ this.#cache.delete(oldest)
76
+ }
77
+ this.#cache.set(url, { buf, ts: Date.now() })
78
+ }
79
+
80
+ /**
81
+ * Fetch a URL into a Buffer. Uses the cache when enabled.
82
+ *
83
+ * @param {string} url
84
+ * @param {object} [opts] Forwarded to fetchBuffer.
85
+ * @returns {Promise<Buffer>}
86
+ */
87
+ async fetch(url, opts) {
88
+ const cached = this.#cacheGet(url)
89
+ if (cached) return cached
90
+ const buf = await fetchBuffer(url, opts)
91
+ if (buf.length) this.#cacheSet(url, buf)
92
+ return buf
93
+ }
94
+
95
+ /**
96
+ * Resize an image buffer.
97
+ *
98
+ * @param {Buffer} buf
99
+ * @param {object} opts
100
+ * @returns {Promise<Buffer>}
101
+ */
102
+ async resize(buf, opts) {
103
+ return resize(buf, opts)
104
+ }
105
+
106
+ /**
107
+ * Generate a square thumbnail.
108
+ *
109
+ * @param {Buffer} buf
110
+ * @param {number} [size=300]
111
+ * @param {'png'|'jpeg'|'webp'} [format]
112
+ * @returns {Promise<Buffer>}
113
+ */
114
+ async thumbnail(buf, size, format) {
115
+ return thumbnail(buf, size, format)
116
+ }
117
+
118
+ /**
119
+ * Get MP4 duration in seconds. Returns 0 on failure (silent default).
120
+ *
121
+ * @param {Buffer} buf
122
+ * @returns {number}
123
+ */
124
+ duration(buf) {
125
+ return getMp4Duration(buf)
126
+ }
127
+
128
+ /**
129
+ * Extract a thumbnail frame from a video buffer.
130
+ *
131
+ * @param {Buffer} buf
132
+ * @param {object} [opts]
133
+ * @returns {Promise<Buffer|string>}
134
+ */
135
+ async videoThumbnail(buf, opts) {
136
+ return extractThumbnail(buf, opts)
137
+ }
138
+
139
+ /**
140
+ * Upload a buffer to WhatsApp.
141
+ *
142
+ * @param {Buffer | { url: string }} source
143
+ * @param {'image'|'video'|'audio'|'document'} mediaType
144
+ * @param {object} [opts]
145
+ * @returns {Promise<string>}
146
+ */
147
+ async upload(source, mediaType, opts) {
148
+ return uploadToWhatsApp(this.conn, source, mediaType, { jid: this.uploadJid, ...opts })
149
+ }
150
+
151
+ /**
152
+ * Resolve a media source (URL | base64 | Buffer | array) per strategy.
153
+ *
154
+ * @param {string | Buffer | Array} source
155
+ * @param {object} [opts]
156
+ * @returns {Promise<string | Buffer | Array<string | Buffer>>}
157
+ */
158
+ async resolve(source, opts) {
159
+ return resolveMedia(this.conn, source, opts)
160
+ }
161
+
162
+ /**
163
+ * Convenience: sniff MIME from a buffer.
164
+ *
165
+ * @param {Buffer} buf
166
+ * @param {string} [filename]
167
+ * @returns {string}
168
+ */
169
+ sniff(buf, filename) {
170
+ return sniffMime(buf, filename)
171
+ }
172
+
173
+ /**
174
+ * Convenience: derive WA media category from MIME.
175
+ *
176
+ * @param {string} mime
177
+ * @returns {'image'|'video'|'audio'|'document'}
178
+ */
179
+ category(mime) {
180
+ return mimeToCategory(mime)
181
+ }
182
+ }
183
+
184
+ export default MediaService