-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathPresenseAssetsStore.mts
More file actions
141 lines (121 loc) · 3.04 KB
/
PresenseAssetsStore.mts
File metadata and controls
141 lines (121 loc) · 3.04 KB
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
import { md5 } from '@takker/md5'
import { encodeBase64, encodeHex } from 'jsr:@std/encoding'
import { env } from './env.mts'
const DO_NOT_MANAGE: string[] = [
'playing',
'paused',
'stopped',
'default',
]
const apiPrefix =
`https://discord.com/api/v9/oauth2/applications/${env.DISCORD_APP_ID}/assets`
async function api<Res, Req = undefined>(
method: 'GET' | 'POST' | 'DELETE',
path: string = '',
body?: Req,
): Promise<Res> {
const res = await fetch(apiPrefix + path, {
headers: {
'Authorization': env.DISCORD_TOKEN,
'Origin': 'https://discord.com',
'Referer':
`https://discord.com/developers/applications/${env.DISCORD_APP_ID}/rich-presence/assets`,
...(body
? {
'Content-Type': 'application/json',
}
: {}),
},
method,
body: body ? JSON.stringify(body) : undefined,
})
const resBody = method === 'DELETE' ? null : await res.json()
if (res.ok) {
return resBody
}
throw new Error(
`${method} ${apiPrefix}${path} ${JSON.stringify(body)} -> ${
JSON.stringify(resBody)
}`,
)
}
type DiscordAsset = {
id: string
name: string
type: 1
}
type DiscordAssetUpload = {
image: string
name: string
type: '1'
}
const log = console.debug.bind(null, '[assets]')
export const cache = new Map<DiscordAsset['name'], DiscordAsset['id']>()
export async function refreshCache() {
cache.clear()
for (const asset of await api<DiscordAsset[]>('GET', '?nocache=true')) {
if (cache.has(asset.name)) {
await remove(asset.name, asset.id)
}
cache.set(asset.name, asset.id)
}
}
await refreshCache()
log('Initialized with cache size', cache.size)
export async function upload(
filePath: string,
): Promise<DiscordAsset['id'] | null> {
const pngBytes = await Deno.readFile(filePath).catch(() => null)
if (pngBytes === null) {
log('Failed to read', filePath)
return cache.get('default') ?? null
}
{
const dv = new DataView(pngBytes.buffer)
// The width and height are 4-byte integers starting
// at byte offset 16 and 20, respectively.
const width = dv.getUint32(16)
const height = dv.getUint32(20)
if (width !== height) {
return null
}
}
const fileData = 'data:image/png;base64,' + encodeBase64(pngBytes)
const hash = encodeHex(md5(fileData))
const existingID = cache.get(hash)
if (existingID) {
return existingID
}
if (cache.size >= 300) {
log('Cache limit hit:', cache.size)
for (
const [name, id] of cache.entries()
.filter(([name]) => !DO_NOT_MANAGE.includes(name))
.take(5)
) {
await remove(name, id)
}
}
try {
const asset = await api<DiscordAsset, DiscordAssetUpload>('POST', '', {
image: fileData,
name: hash,
type: '1',
})
cache.set(asset.name, asset.id)
log(`Uploaded ${filePath}; Cache size is now ${cache.size}`)
return asset.id
} catch {
log(`Upload of ${filePath} failed; Cache reinvalidated`)
await refreshCache()
return upload(filePath)
}
}
export async function remove(
name: DiscordAsset['name'],
id: DiscordAsset['id'],
) {
await api('DELETE', '/' + id)
cache.delete(name)
log(`Removed ${id}; Cache size is now ${cache.size}`)
}