/
payload.ts
246 lines (237 loc) · 7.98 KB
/
payload.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
import {
itrToStream,
streamFile,
InputFile,
InputMedia,
debug as d,
} from '../platform.ts'
const debug = d('grammy:warn')
// === JSON serialization where necessary
/**
* Performs a JSON serialization on some nested properties inside the payload as
* required by the Telegram Bot API specification.
*/
export function transformPayload(
method: string,
payload: Record<string, unknown>
) {
const entries = Object.entries(payload).map(kv => {
const [key, value] = kv
if (mustSerialize(method, key)) {
kv = [key, JSON.stringify(value)]
}
return kv
})
return Object.fromEntries(entries)
}
// === Payload types (JSON vs. form data)
/**
* Determines for a given payload if it may be sent as JSON, or if it has to be
* uploaded via multipart/form-data. Returns `true` in the latter case and
* `false` in the former.
*
* @param payload the payload to analyse
*/
export function requiresFormDataUpload(payload: unknown): boolean {
return (
typeof payload === 'object' &&
payload !== null &&
Object.values(payload).some(v =>
Array.isArray(v)
? v.some(requiresFormDataUpload)
: v instanceof InputFile || requiresFormDataUpload(v)
)
)
}
/**
* Turns a payload into an options object that can be passed to a `fetch` call
* by setting the necessary headers and method. May only be called for payloads
* P that let `requiresFormDataUpload(P)` return `false`.
*
* @param payload the payload to wrap
*/
export function createJsonPayload(payload: Record<string, unknown>) {
return {
method: 'POST',
headers: {
'content-type': 'application/json',
connection: 'keep-alive',
},
body: JSON.stringify(payload, (_, v) => v ?? undefined),
}
}
/**
* Turns a payload into an options object that can be passed to a `fetch` call
* by setting the necessary headers and method. Note that this method creates a
* multipart/form-data stream under the hood. If possible, a JSON payload should
* be created instead for performance reasons.
*
* @param payload the payload to wrap
*/
export function createFormDataPayload(payload: Record<string, unknown>) {
const boundary = createBoundary()
return {
method: 'POST',
headers: {
'content-type': `multipart/form-data; boundary=${boundary}`,
connection: 'keep-alive',
},
body: itrToStream(payloadToMultipartItr(payload, boundary)),
}
}
// === Form data creation
function createBoundary() {
// Taken from Deno std lib
return '----------' + randomId(32)
}
function randomId(length = 16) {
return Array.from(Array(length))
.map(() => Math.random().toString(36)[2] || 0)
.join('')
}
const enc = new TextEncoder()
async function* payloadToMultipartItr(
payload: Record<string, unknown>,
boundary = createBoundary()
): AsyncGenerator<Uint8Array> {
yield enc.encode(`--${boundary}\r\n`)
const separator = enc.encode(`\r\n--${boundary}\r\n`)
let first = true
for (const [key, value] of Object.entries(payload)) {
if (!first) yield separator
if (value instanceof InputFile) {
// InputFile in payload
if (mustAttachIndirectly(key)) {
const id = randomId()
yield* filePart(id, key, value)
yield valuePart(key, `attach://${id}`)
} else {
yield* filePart(key, key, value)
}
} else if (isInputMedia(value)) {
// InputMedia* in payload
if (value.media instanceof InputFile) {
const id = randomId()
yield* filePart(id, key, value.media)
value.media = `attach://${id}`
}
yield valuePart(key, JSON.stringify(value))
} else if (Array.isArray(value)) {
// Array in payload (elements might be InputMedia*)
for (const elem of value) {
if (isInputMedia(elem) && elem.media instanceof InputFile) {
const id = randomId()
yield* filePart(id, key, elem.media)
yield separator
elem.media = `attach://${id}`
}
}
yield valuePart(key, JSON.stringify(value))
} else {
// other value in payload
yield valuePart(
key,
typeof value === 'object' ? JSON.stringify(value) : value
)
}
first = false
}
yield enc.encode(`\r\n--${boundary}--`)
}
/** Turns a regular value into a `Uint8Array` */
function valuePart(key: string, value: unknown): Uint8Array {
return enc.encode(
`content-disposition:form-data;name="${key}"\r\n\r\n${value}`
)
}
/** Turns an InputFile into a generator of `Uint8Array`s */
async function* filePart(
id: string,
key: string,
input: InputFile
): AsyncGenerator<Uint8Array> {
const filename = input.filename ?? `${key}.${getExt(key)}`
if (filename.includes(';') || filename.includes('"')) {
debug('WARNING: Telegram Bot API currently does not support')
debug('sending filenames that contain semicolons or double quotes')
debug('(or both), confer https://github.com/tdlib/td/issues/1459.')
debug('While grammY will send the correct data, the Telegram')
debug('Bot API will discard everything after the first semicolon,')
debug('and it will convert the double quotes into spaces.')
}
yield enc.encode(
`content-disposition:form-data;name="${id}";filename=${filename}\r\n\r\n`
)
if (input.file instanceof Uint8Array) {
// `input.file` is a buffer
yield input.file
} else {
const stream =
typeof input.file === 'string'
? await streamFile(input.file) // `input.file` is a file path
: input.file // `input.file` is a stream
for await (const chunk of stream) yield chunk
}
}
/** Returns the default file extension for an API property name */
function getExt(key: string) {
switch (key) {
case 'photo':
return 'jpg'
case 'voice':
return 'ogg'
case 'audio':
return 'mp3'
case 'animation':
case 'video':
case 'video_note':
return 'mp4'
case 'sticker':
return 'webp'
default:
return 'dat'
}
}
// === Helper functions
/** Fields that have to be JSON serialized in all methods */
const serializationFields = new Set([
'allowed_updates',
'reply_markup',
'options',
'commands',
'mask_position',
'results',
'prices',
'shipping_options',
'errors',
])
/** Fields that have to be JSON serialized only for some methods */
const serializationMethodFields = new Set(['restrictChatMember:permissions'])
/** Determines if a given key in a given method should be JSON-serialized */
function mustSerialize(method: string, key: string) {
return (
serializationFields.has(key) ||
serializationMethodFields.has(`${method}:${key}`)
)
}
/** Fields that require a multipart/form-data upload via ID instead of via the property itself */
const indirectAttachmentFields = new Set(['thumb'])
/** Determines if a file behind a given key should be send via `attach://<id>` instead of the key itself */
function mustAttachIndirectly(key: string) {
return indirectAttachmentFields.has(key)
}
function has<K extends readonly string[]>(
obj: unknown,
props: K
): obj is Record<K[number], unknown> {
return typeof obj === 'object' && obj !== null && props.every(p => p in obj)
}
const inputMediaProps = ['type', 'media'] as const
/** Determines if a value is an `InputMedia` object */
function isInputMedia(value: unknown): value is InputMedia {
return (
has(value, inputMediaProps) &&
typeof value.type === 'string' &&
(typeof value.media === 'string' || value.media instanceof InputFile)
)
}