alexa-ai 2.1.1
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/CHANGELOG.md +136 -0
- package/LICENSE +15 -0
- package/README.md +862 -0
- package/examples/bot-ai.js +531 -0
- package/examples/demo.js +147 -0
- package/index.js +75 -0
- package/package.json +50 -0
- package/src/AlexaAI.js +1099 -0
- package/src/core/Config.js +249 -0
- package/src/core/DeepAIClient.js +789 -0
- package/src/core/Endpoints.js +74 -0
- package/src/core/Persona.js +102 -0
- package/src/core/StreamParser.js +157 -0
- package/src/core/SystemPrompt.js +7 -0
- package/src/core/errors.js +51 -0
- package/src/db/Database.js +161 -0
- package/src/db/schema.sql +214 -0
- package/src/repositories/ConversationRepository.js +206 -0
- package/src/repositories/IdentityRepository.js +244 -0
- package/src/repositories/MemoryRepository.js +215 -0
- package/src/repositories/UserRepository.js +275 -0
- package/src/services/AmnesiaGuard.js +176 -0
- package/src/services/FactMiner.js +151 -0
- package/src/services/IdentityGuard.js +203 -0
- package/src/services/IdentityResolver.js +179 -0
- package/src/services/ImageDescriber.js +335 -0
- package/src/services/MathDetector.js +64 -0
- package/src/services/MemoryExtractor.js +142 -0
- package/src/services/PromptBuilder.js +216 -0
- package/src/services/ResponseFormatter.js +121 -0
- package/src/services/TriggerDetector.js +182 -0
- package/src/services/WebAnswer.js +573 -0
- package/src/utils/JidParser.js +148 -0
- package/src/utils/Media.js +235 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* JidParser
|
|
5
|
+
* ---------
|
|
6
|
+
* Normalises every WhatsApp identifier shape the bot may hand us.
|
|
7
|
+
*
|
|
8
|
+
* Supported inputs:
|
|
9
|
+
* 78151912841263@lid -> linked-device / privacy id (user)
|
|
10
|
+
* 94771234567@s.whatsapp.net -> classic phone jid (user)
|
|
11
|
+
* 94771234567@c.us -> legacy web jid (user)
|
|
12
|
+
* 94771234567:12@s.whatsapp.net -> jid with device suffix (user)
|
|
13
|
+
* 120363413125431525@g.us -> group (group)
|
|
14
|
+
* xxxxx@broadcast / @newsletter -> broadcast / channel
|
|
15
|
+
*
|
|
16
|
+
* The parser is intentionally forgiving: WhatsApp libraries pass slightly
|
|
17
|
+
* different shapes depending on version, and a memory system must never lose
|
|
18
|
+
* a user just because a `:device` suffix appeared.
|
|
19
|
+
*/
|
|
20
|
+
class JidParser {
|
|
21
|
+
static SERVER_LID = 'lid';
|
|
22
|
+
static SERVER_USER = 's.whatsapp.net';
|
|
23
|
+
static SERVER_LEGACY = 'c.us';
|
|
24
|
+
static SERVER_GROUP = 'g.us';
|
|
25
|
+
static SERVER_BROADCAST = 'broadcast';
|
|
26
|
+
static SERVER_NEWSLETTER = 'newsletter';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parse any jid into a stable descriptor.
|
|
30
|
+
* @param {string} rawJid
|
|
31
|
+
* @returns {{
|
|
32
|
+
* raw: string, jid: string, local: string, server: string,
|
|
33
|
+
* type: 'lid'|'user'|'group'|'broadcast'|'newsletter'|'unknown',
|
|
34
|
+
* isGroup: boolean, isUser: boolean, isLid: boolean, device: number|null,
|
|
35
|
+
* phone: string|null, valid: boolean
|
|
36
|
+
* }}
|
|
37
|
+
*/
|
|
38
|
+
static parse(rawJid) {
|
|
39
|
+
const empty = {
|
|
40
|
+
raw: rawJid == null ? '' : String(rawJid),
|
|
41
|
+
jid: '',
|
|
42
|
+
local: '',
|
|
43
|
+
server: '',
|
|
44
|
+
type: 'unknown',
|
|
45
|
+
isGroup: false,
|
|
46
|
+
isUser: false,
|
|
47
|
+
isLid: false,
|
|
48
|
+
device: null,
|
|
49
|
+
phone: null,
|
|
50
|
+
valid: false,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
if (rawJid == null) return empty;
|
|
54
|
+
|
|
55
|
+
const raw = String(rawJid).trim();
|
|
56
|
+
if (!raw) return empty;
|
|
57
|
+
|
|
58
|
+
// Strip anything after a space (some libs append push names)
|
|
59
|
+
const cleaned = raw.split(/\s+/)[0];
|
|
60
|
+
|
|
61
|
+
const at = cleaned.lastIndexOf('@');
|
|
62
|
+
let local = at === -1 ? cleaned : cleaned.slice(0, at);
|
|
63
|
+
const server = at === -1 ? '' : cleaned.slice(at + 1).toLowerCase();
|
|
64
|
+
|
|
65
|
+
// Split device suffix -> "94771234567:12" => local 94771234567, device 12
|
|
66
|
+
let device = null;
|
|
67
|
+
const colon = local.indexOf(':');
|
|
68
|
+
if (colon !== -1) {
|
|
69
|
+
const devPart = local.slice(colon + 1);
|
|
70
|
+
local = local.slice(0, colon);
|
|
71
|
+
const parsedDev = Number.parseInt(devPart, 10);
|
|
72
|
+
device = Number.isNaN(parsedDev) ? null : parsedDev;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
local = local.replace(/[^0-9a-zA-Z_-]/g, '');
|
|
76
|
+
|
|
77
|
+
let type = 'unknown';
|
|
78
|
+
if (server === JidParser.SERVER_GROUP) type = 'group';
|
|
79
|
+
else if (server === JidParser.SERVER_LID) type = 'lid';
|
|
80
|
+
else if (server === JidParser.SERVER_USER || server === JidParser.SERVER_LEGACY) type = 'user';
|
|
81
|
+
else if (server === JidParser.SERVER_BROADCAST) type = 'broadcast';
|
|
82
|
+
else if (server === JidParser.SERVER_NEWSLETTER) type = 'newsletter';
|
|
83
|
+
else if (!server && /^\d{6,}$/.test(local)) type = 'user'; // bare number
|
|
84
|
+
|
|
85
|
+
const isGroup = type === 'group';
|
|
86
|
+
const isLid = type === 'lid';
|
|
87
|
+
const isUser = type === 'user' || isLid;
|
|
88
|
+
|
|
89
|
+
// Only a real phone jid yields a usable phone number. @lid is a privacy
|
|
90
|
+
// id and must NEVER be treated as a phone number.
|
|
91
|
+
const phone = type === 'user' && /^\d{6,}$/.test(local) ? local : null;
|
|
92
|
+
|
|
93
|
+
const normalisedServer = server || (type === 'user' ? JidParser.SERVER_USER : '');
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
raw,
|
|
97
|
+
jid: normalisedServer ? `${local}@${normalisedServer}` : local,
|
|
98
|
+
local,
|
|
99
|
+
server: normalisedServer,
|
|
100
|
+
type,
|
|
101
|
+
isGroup,
|
|
102
|
+
isUser,
|
|
103
|
+
isLid,
|
|
104
|
+
device,
|
|
105
|
+
phone,
|
|
106
|
+
valid: Boolean(local) && type !== 'unknown',
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Canonical, device-stripped jid used as the DB primary key for a user/group.
|
|
112
|
+
* @param {string} rawJid
|
|
113
|
+
* @returns {string}
|
|
114
|
+
*/
|
|
115
|
+
static normalize(rawJid) {
|
|
116
|
+
return JidParser.parse(rawJid).jid;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
static isGroup(rawJid) {
|
|
120
|
+
return JidParser.parse(rawJid).isGroup;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
static isUser(rawJid) {
|
|
124
|
+
return JidParser.parse(rawJid).isUser;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
static isLid(rawJid) {
|
|
128
|
+
return JidParser.parse(rawJid).isLid;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Build the conversation context key.
|
|
133
|
+
* DM -> "dm:<userJid>"
|
|
134
|
+
* Group -> "group:<groupJid>:<userJid>" (per-user thread inside a group)
|
|
135
|
+
* @param {string} userJid
|
|
136
|
+
* @param {string} [groupJid]
|
|
137
|
+
* @param {boolean} [sharedGroupThread=false] one thread for the whole group
|
|
138
|
+
* @returns {string}
|
|
139
|
+
*/
|
|
140
|
+
static contextKey(userJid, groupJid, sharedGroupThread = false) {
|
|
141
|
+
const user = JidParser.normalize(userJid);
|
|
142
|
+
const group = groupJid ? JidParser.normalize(groupJid) : '';
|
|
143
|
+
if (!group) return `dm:${user}`;
|
|
144
|
+
return sharedGroupThread ? `group:${group}` : `group:${group}:${user}`;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = JidParser;
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Media
|
|
5
|
+
* -----
|
|
6
|
+
* One place that turns "whatever the bot handed us" into the media shape the
|
|
7
|
+
* engine works with:
|
|
8
|
+
*
|
|
9
|
+
* { buffer: Buffer, mimetype: 'image/png', filename: 'image.png' }
|
|
10
|
+
* { url: 'https://…' }
|
|
11
|
+
*
|
|
12
|
+
* Every public method that takes an image (`chat({ image })`, `describeImage`,
|
|
13
|
+
* `editImage`, `upscaleImage`, `detectNsfw`, `ask()`) runs its input through
|
|
14
|
+
* `normalize()`, so all of them accept the same inputs:
|
|
15
|
+
*
|
|
16
|
+
* Buffer · Uint8Array · ArrayBuffer
|
|
17
|
+
* 'data:image/png;base64,…' data URI
|
|
18
|
+
* '<raw base64>' e.g. whatsapp-web.js `media.data`
|
|
19
|
+
* 'https://…' remote URL
|
|
20
|
+
* { buffer, mimetype?, filename? } Baileys downloadMediaMessage()
|
|
21
|
+
* { base64 } · { data } whatsapp-web.js MessageMedia
|
|
22
|
+
* { url }
|
|
23
|
+
*
|
|
24
|
+
* Before this existed each method had its own partial check: a bare Buffer
|
|
25
|
+
* was "unreadable" to `describeImage()`, and `{ base64 }` was silently dropped
|
|
26
|
+
* by the `/api/*` helpers, so the request went out with no image at all.
|
|
27
|
+
*/
|
|
28
|
+
class Media {
|
|
29
|
+
static DEFAULT_IMAGE_MIME = 'image/jpeg';
|
|
30
|
+
|
|
31
|
+
/** Extension used for the default filename of each mimetype. */
|
|
32
|
+
static EXTENSIONS = {
|
|
33
|
+
'image/jpeg': 'jpg',
|
|
34
|
+
'image/jpg': 'jpg',
|
|
35
|
+
'image/png': 'png',
|
|
36
|
+
'image/webp': 'webp',
|
|
37
|
+
'image/gif': 'gif',
|
|
38
|
+
'image/bmp': 'bmp',
|
|
39
|
+
'application/pdf': 'pdf',
|
|
40
|
+
'text/plain': 'txt',
|
|
41
|
+
'text/markdown': 'md',
|
|
42
|
+
'text/csv': 'csv',
|
|
43
|
+
'application/json': 'json',
|
|
44
|
+
'application/msword': 'doc',
|
|
45
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {any} input see the list above
|
|
50
|
+
* @returns {{buffer?:Buffer, url?:string, mimetype?:string, filename?:string}|null}
|
|
51
|
+
*/
|
|
52
|
+
static normalize(input) {
|
|
53
|
+
if (input == null) return null;
|
|
54
|
+
|
|
55
|
+
// ---- raw bytes -------------------------------------------------------
|
|
56
|
+
const bytes = Media._toBuffer(input);
|
|
57
|
+
if (bytes) return Media._withDefaults({ buffer: bytes });
|
|
58
|
+
|
|
59
|
+
// ---- strings: data URI, URL, raw base64 ---------------------------------
|
|
60
|
+
if (typeof input === 'string') {
|
|
61
|
+
const str = input.trim();
|
|
62
|
+
if (!str) return null;
|
|
63
|
+
|
|
64
|
+
const dataUri = Media._decodeDataUri(str);
|
|
65
|
+
if (dataUri) return Media._withDefaults(dataUri);
|
|
66
|
+
|
|
67
|
+
if (/^https?:\/\//i.test(str)) return { url: str };
|
|
68
|
+
|
|
69
|
+
const decoded = Media._decodeBase64(str);
|
|
70
|
+
if (decoded) return Media._withDefaults({ buffer: decoded });
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (typeof input !== 'object') return null;
|
|
75
|
+
|
|
76
|
+
// ---- objects -------------------------------------------------------------
|
|
77
|
+
const meta = {
|
|
78
|
+
mimetype: Media._cleanMime(input.mimetype || input.mimeType || input.type),
|
|
79
|
+
filename: Media._cleanName(input.filename || input.fileName || input.name),
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const inner = Media._toBuffer(input.buffer);
|
|
83
|
+
if (inner) return Media._withDefaults({ ...input, ...meta, buffer: inner });
|
|
84
|
+
|
|
85
|
+
const inline = input.base64 ?? input.data;
|
|
86
|
+
if (typeof inline === 'string' && inline.trim()) {
|
|
87
|
+
const asUri = Media._decodeDataUri(inline.trim());
|
|
88
|
+
if (asUri) {
|
|
89
|
+
return Media._withDefaults({ ...input, ...meta, mimetype: meta.mimetype || asUri.mimetype, buffer: asUri.buffer });
|
|
90
|
+
}
|
|
91
|
+
const decoded = Media._decodeBase64(inline);
|
|
92
|
+
if (decoded) return Media._withDefaults({ ...input, ...meta, buffer: decoded });
|
|
93
|
+
}
|
|
94
|
+
// `data` may also be a nested Buffer/Uint8Array (some libraries do this).
|
|
95
|
+
const nested = Media._toBuffer(input.data);
|
|
96
|
+
if (nested) return Media._withDefaults({ ...input, ...meta, buffer: nested });
|
|
97
|
+
|
|
98
|
+
if (typeof input.url === 'string' && /^https?:\/\//i.test(input.url.trim())) {
|
|
99
|
+
const out = { ...input, url: input.url.trim() };
|
|
100
|
+
delete out.buffer;
|
|
101
|
+
delete out.base64;
|
|
102
|
+
delete out.data;
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Shape used for DeepAI's classic `/api/*` family (`runApi`): a URL string
|
|
111
|
+
* is sent as a plain field, bytes as a file upload that keeps its mimetype
|
|
112
|
+
* and filename.
|
|
113
|
+
* @returns {string|{buffer:Buffer, mimetype:string, filename:string}|null}
|
|
114
|
+
*/
|
|
115
|
+
static toApiField(input) {
|
|
116
|
+
const media = Media.normalize(input);
|
|
117
|
+
if (!media) return null;
|
|
118
|
+
if (media.url) return media.url;
|
|
119
|
+
return { buffer: media.buffer, mimetype: media.mimetype, filename: media.filename };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** True when the media is a text-bearing document rather than a picture. */
|
|
123
|
+
static isDocument(media) {
|
|
124
|
+
if (!media) return false;
|
|
125
|
+
const mime = String(media.mimetype || '').toLowerCase();
|
|
126
|
+
const name = String(media.filename || '').toLowerCase();
|
|
127
|
+
if (mime.startsWith('image/')) return false;
|
|
128
|
+
return (
|
|
129
|
+
/^(text\/|application\/(pdf|json|xml|rtf|msword|vnd\.))/.test(mime) ||
|
|
130
|
+
/\.(txt|pdf|docx?|csv|md|json|xml|rtf|pptx?|xlsx?|log)$/.test(name)
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Detect the real content type from the first bytes. Bots frequently
|
|
136
|
+
* label everything `image/jpeg`; DeepAI's upload uses the Blob type.
|
|
137
|
+
* @returns {string|null}
|
|
138
|
+
*/
|
|
139
|
+
static sniff(buffer) {
|
|
140
|
+
if (!buffer || buffer.length < 4) return null;
|
|
141
|
+
const b = buffer;
|
|
142
|
+
if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'image/png';
|
|
143
|
+
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg';
|
|
144
|
+
if (b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38) return 'image/gif';
|
|
145
|
+
if (b[0] === 0x25 && b[1] === 0x50 && b[2] === 0x44 && b[3] === 0x46) return 'application/pdf';
|
|
146
|
+
if (b[0] === 0x42 && b[1] === 0x4d) return 'image/bmp';
|
|
147
|
+
if (
|
|
148
|
+
b.length >= 12 &&
|
|
149
|
+
b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 &&
|
|
150
|
+
b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50
|
|
151
|
+
) {
|
|
152
|
+
return 'image/webp';
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ------------------------------------------------------------ helpers ---
|
|
158
|
+
|
|
159
|
+
/** @private Buffer | Uint8Array | ArrayBuffer -> Buffer (null otherwise). */
|
|
160
|
+
static _toBuffer(value) {
|
|
161
|
+
if (value == null) return null;
|
|
162
|
+
if (Buffer.isBuffer(value)) return value.length ? value : null;
|
|
163
|
+
if (value instanceof Uint8Array) return value.length ? Buffer.from(value) : null;
|
|
164
|
+
if (value instanceof ArrayBuffer) return value.byteLength ? Buffer.from(value) : null;
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** @private `data:<mime>;base64,<payload>` -> { buffer, mimetype } */
|
|
169
|
+
static _decodeDataUri(str) {
|
|
170
|
+
const match = /^data:([a-z0-9.+/-]+)?(?:;[a-z0-9=-]+)*;base64,([\s\S]+)$/i.exec(str);
|
|
171
|
+
if (!match) return null;
|
|
172
|
+
const buffer = Media._decodeBase64(match[2]);
|
|
173
|
+
if (!buffer) return null;
|
|
174
|
+
return { buffer, mimetype: Media._cleanMime(match[1]) };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* @private Raw base64 -> Buffer. Only strings that really look like base64
|
|
179
|
+
* qualify, so an ordinary sentence is never mistaken for an image.
|
|
180
|
+
*/
|
|
181
|
+
static _decodeBase64(str) {
|
|
182
|
+
const raw = String(str);
|
|
183
|
+
// Real base64 payloads contain no spaces (only line breaks, if
|
|
184
|
+
// anything). A sentence with spaces is prose, not an image.
|
|
185
|
+
if (/[ \t]/.test(raw.trim())) return null;
|
|
186
|
+
const compact = raw.replace(/\s+/g, '');
|
|
187
|
+
if (compact.length < 32) return null;
|
|
188
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(compact) && !/^[A-Za-z0-9_-]+={0,2}$/.test(compact)) return null;
|
|
189
|
+
// Base64 of any real file mixes cases and digits; a lower-case word
|
|
190
|
+
// run ("helloworldhelloworld…") is not media.
|
|
191
|
+
if (!/[A-Z]/.test(compact) || !/[a-z]/.test(compact) || !/[0-9+/_-]/.test(compact)) return null;
|
|
192
|
+
try {
|
|
193
|
+
const buffer = Buffer.from(compact, compact.includes('-') || compact.includes('_') ? 'base64url' : 'base64');
|
|
194
|
+
return buffer.length ? buffer : null;
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** @private fill mimetype (sniffed when missing/generic) and filename. */
|
|
201
|
+
static _withDefaults(media) {
|
|
202
|
+
const out = { ...media };
|
|
203
|
+
delete out.base64;
|
|
204
|
+
delete out.data;
|
|
205
|
+
delete out.url;
|
|
206
|
+
|
|
207
|
+
const sniffed = Media.sniff(out.buffer);
|
|
208
|
+
const declared = Media._cleanMime(out.mimetype);
|
|
209
|
+
out.mimetype = (declared && declared !== 'application/octet-stream' ? declared : null) || sniffed || Media.DEFAULT_IMAGE_MIME;
|
|
210
|
+
// A wrong label ("image/jpeg" for a PNG) is corrected when the bytes say otherwise.
|
|
211
|
+
if (sniffed && declared && declared.startsWith('image/') && sniffed.startsWith('image/') && sniffed !== declared) {
|
|
212
|
+
out.mimetype = sniffed;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!out.filename) {
|
|
216
|
+
const ext = Media.EXTENSIONS[out.mimetype] || (out.mimetype.split('/')[1] || 'bin').replace(/[^a-z0-9]/gi, '') || 'bin';
|
|
217
|
+
out.filename = `${out.mimetype.startsWith('image/') ? 'image' : 'document'}.${ext}`;
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
static _cleanMime(value) {
|
|
223
|
+
if (!value || typeof value !== 'string') return null;
|
|
224
|
+
const mime = value.trim().toLowerCase().split(';')[0];
|
|
225
|
+
return /^[a-z0-9.+-]+\/[a-z0-9.+-]+$/.test(mime) ? mime : null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
static _cleanName(value) {
|
|
229
|
+
if (!value || typeof value !== 'string') return null;
|
|
230
|
+
const name = value.trim().replace(/[\\/:*?"<>|\u0000-\u001F]/g, '_').slice(0, 128);
|
|
231
|
+
return name || null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
module.exports = Media;
|