diff --git a/package.json b/package.json index 28c9301..1b90e56 100644 --- a/package.json +++ b/package.json @@ -33,17 +33,20 @@ "dist/**/*.js.map" ], "devDependencies": { + "@types/debug": "^4.1.12", "@types/node": "^20.11.17", "@types/react": "^18.2.47", "@types/react-dom": "^18.2.18", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", + "debug": "^4.3.4", "discord.js": "^14.14.1", "dotenv": "^16.3.1", "eslint": "^8.56.0", "husky": "^8.0.3", "prettier": "^2.8.8", "pretty-quick": "^3.1.3", + "sharp": "^0.33.2", "ts-node": "^10.9.2", "typescript": "^5.3.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17214e3..792102c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,6 +31,9 @@ dependencies: version: 5.28.2 devDependencies: + '@types/debug': + specifier: ^4.1.12 + version: 4.1.12 '@types/node': specifier: ^20.11.17 version: 20.11.17 @@ -46,6 +49,9 @@ devDependencies: '@typescript-eslint/parser': specifier: ^5.62.0 version: 5.62.0(eslint@8.56.0)(typescript@5.3.3) + debug: + specifier: ^4.3.4 + version: 4.3.4 discord.js: specifier: ^14.14.1 version: 14.14.1 @@ -64,6 +70,9 @@ devDependencies: pretty-quick: specifier: ^3.1.3 version: 3.1.3(prettier@2.8.8) + sharp: + specifier: ^0.33.2 + version: 0.33.2 ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@20.11.17)(typescript@5.3.3) @@ -176,6 +185,14 @@ packages: - utf-8-validate dev: true + /@emnapi/runtime@0.45.0: + resolution: {integrity: sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w==} + requiresBuild: true + dependencies: + tslib: 2.6.2 + dev: true + optional: true + /@eslint-community/eslint-utils@4.4.0(eslint@8.56.0): resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -237,6 +254,194 @@ packages: resolution: {integrity: sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==} dev: true + /@img/sharp-darwin-arm64@0.33.2: + resolution: {integrity: sha512-itHBs1rPmsmGF9p4qRe++CzCgd+kFYktnsoR1sbIAfsRMrJZau0Tt1AH9KVnufc2/tU02Gf6Ibujx+15qRE03w==} + engines: {glibc: '>=2.26', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.0.1 + dev: true + optional: true + + /@img/sharp-darwin-x64@0.33.2: + resolution: {integrity: sha512-/rK/69Rrp9x5kaWBjVN07KixZanRr+W1OiyKdXcbjQD6KbW+obaTeBBtLUAtbBsnlTTmWthw99xqoOS7SsySDg==} + engines: {glibc: '>=2.26', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [x64] + os: [darwin] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.0.1 + dev: true + optional: true + + /@img/sharp-libvips-darwin-arm64@1.0.1: + resolution: {integrity: sha512-kQyrSNd6lmBV7O0BUiyu/OEw9yeNGFbQhbxswS1i6rMDwBBSX+e+rPzu3S+MwAiGU3HdLze3PanQ4Xkfemgzcw==} + engines: {macos: '>=11', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@img/sharp-libvips-darwin-x64@1.0.1: + resolution: {integrity: sha512-eVU/JYLPVjhhrd8Tk6gosl5pVlvsqiFlt50wotCvdkFGf+mDNBJxMh+bvav+Wt3EBnNZWq8Sp2I7XfSjm8siog==} + engines: {macos: '>=10.13', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@img/sharp-libvips-linux-arm64@1.0.1: + resolution: {integrity: sha512-bnGG+MJjdX70mAQcSLxgeJco11G+MxTz+ebxlz8Y3dxyeb3Nkl7LgLI0mXupoO+u1wRNx/iRj5yHtzA4sde1yA==} + engines: {glibc: '>=2.26', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@img/sharp-libvips-linux-arm@1.0.1: + resolution: {integrity: sha512-FtdMvR4R99FTsD53IA3LxYGghQ82t3yt0ZQ93WMZ2xV3dqrb0E8zq4VHaTOuLEAuA83oDawHV3fd+BsAPadHIQ==} + engines: {glibc: '>=2.28', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@img/sharp-libvips-linux-s390x@1.0.1: + resolution: {integrity: sha512-3+rzfAR1YpMOeA2zZNp+aYEzGNWK4zF3+sdMxuCS3ey9HhDbJ66w6hDSHDMoap32DueFwhhs3vwooAB2MaK4XQ==} + engines: {glibc: '>=2.28', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@img/sharp-libvips-linux-x64@1.0.1: + resolution: {integrity: sha512-3NR1mxFsaSgMMzz1bAnnKbSAI+lHXVTqAHgc1bgzjHuXjo4hlscpUxc0vFSAPKI3yuzdzcZOkq7nDPrP2F8Jgw==} + engines: {glibc: '>=2.26', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@img/sharp-libvips-linuxmusl-arm64@1.0.1: + resolution: {integrity: sha512-5aBRcjHDG/T6jwC3Edl3lP8nl9U2Yo8+oTl5drd1dh9Z1EBfzUKAJFUDTDisDjUwc7N4AjnPGfCA3jl3hY8uDg==} + engines: {musl: '>=1.2.2', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@img/sharp-libvips-linuxmusl-x64@1.0.1: + resolution: {integrity: sha512-dcT7inI9DBFK6ovfeWRe3hG30h51cBAP5JXlZfx6pzc/Mnf9HFCQDLtYf4MCBjxaaTfjCCjkBxcy3XzOAo5txw==} + engines: {musl: '>=1.2.2', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@img/sharp-linux-arm64@0.33.2: + resolution: {integrity: sha512-pz0NNo882vVfqJ0yNInuG9YH71smP4gRSdeL09ukC2YLE6ZyZePAlWKEHgAzJGTiOh8Qkaov6mMIMlEhmLdKew==} + engines: {glibc: '>=2.26', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [arm64] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.0.1 + dev: true + optional: true + + /@img/sharp-linux-arm@0.33.2: + resolution: {integrity: sha512-Fndk/4Zq3vAc4G/qyfXASbS3HBZbKrlnKZLEJzPLrXoJuipFNNwTes71+Ki1hwYW5lch26niRYoZFAtZVf3EGA==} + engines: {glibc: '>=2.28', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [arm] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.0.1 + dev: true + optional: true + + /@img/sharp-linux-s390x@0.33.2: + resolution: {integrity: sha512-MBoInDXDppMfhSzbMmOQtGfloVAflS2rP1qPcUIiITMi36Mm5YR7r0ASND99razjQUpHTzjrU1flO76hKvP5RA==} + engines: {glibc: '>=2.28', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [s390x] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.0.1 + dev: true + optional: true + + /@img/sharp-linux-x64@0.33.2: + resolution: {integrity: sha512-xUT82H5IbXewKkeF5aiooajoO1tQV4PnKfS/OZtb5DDdxS/FCI/uXTVZ35GQ97RZXsycojz/AJ0asoz6p2/H/A==} + engines: {glibc: '>=2.26', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [x64] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.0.1 + dev: true + optional: true + + /@img/sharp-linuxmusl-arm64@0.33.2: + resolution: {integrity: sha512-F+0z8JCu/UnMzg8IYW1TMeiViIWBVg7IWP6nE0p5S5EPQxlLd76c8jYemG21X99UzFwgkRo5yz2DS+zbrnxZeA==} + engines: {musl: '>=1.2.2', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [arm64] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.0.1 + dev: true + optional: true + + /@img/sharp-linuxmusl-x64@0.33.2: + resolution: {integrity: sha512-+ZLE3SQmSL+Fn1gmSaM8uFusW5Y3J9VOf+wMGNnTtJUMUxFhv+P4UPaYEYT8tqnyYVaOVGgMN/zsOxn9pSsO2A==} + engines: {musl: '>=1.2.2', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [x64] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.0.1 + dev: true + optional: true + + /@img/sharp-wasm32@0.33.2: + resolution: {integrity: sha512-fLbTaESVKuQcpm8ffgBD7jLb/CQLcATju/jxtTXR1XCLwbOQt+OL5zPHSDMmp2JZIeq82e18yE0Vv7zh6+6BfQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [wasm32] + requiresBuild: true + dependencies: + '@emnapi/runtime': 0.45.0 + dev: true + optional: true + + /@img/sharp-win32-ia32@0.33.2: + resolution: {integrity: sha512-okBpql96hIGuZ4lN3+nsAjGeggxKm7hIRu9zyec0lnfB8E7Z6p95BuRZzDDXZOl2e8UmR4RhYt631i7mfmKU8g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@img/sharp-win32-x64@0.33.2: + resolution: {integrity: sha512-E4magOks77DK47FwHUIGH0RYWSgRBfGdK56kIHSVeB9uIS4pPFr4N2kIVsXdQQo4LzOsENKV5KAhRlRL7eMAdg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@jridgewell/resolve-uri@3.1.1: resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==} engines: {node: '>=6.0.0'} @@ -314,6 +519,12 @@ packages: resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} dev: true + /@types/debug@4.1.12: + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + dependencies: + '@types/ms': 0.7.34 + dev: true + /@types/json-schema@7.0.15: resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} dev: true @@ -322,6 +533,10 @@ packages: resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==} dev: true + /@types/ms@0.7.34: + resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} + dev: true + /@types/node@18.19.6: resolution: {integrity: sha512-X36s5CXMrrJOs2lQCdDF68apW4Rfx9ixYMawlepwmE4Anezv/AV2LSpKD1Ub8DAc+urp5bk0BGZ6NtmBitfnsg==} dependencies: @@ -620,6 +835,21 @@ packages: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} dev: true + /color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.2 + dev: true + + /color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + dev: true + /concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} dev: true @@ -656,6 +886,11 @@ packages: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true + /detect-libc@2.0.2: + resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} + engines: {node: '>=8'} + dev: true + /diff@4.0.2: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} @@ -1043,6 +1278,10 @@ packages: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} dev: true + /is-arrayish@0.3.2: + resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} + dev: true + /is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1423,6 +1662,36 @@ packages: lru-cache: 6.0.0 dev: true + /sharp@0.33.2: + resolution: {integrity: sha512-WlYOPyyPDiiM07j/UO+E720ju6gtNtHjEGg5vovUk1Lgxyjm2LFO+37Nt/UI3MMh2l6hxTWQWi7qk3cXJTutcQ==} + engines: {libvips: '>=8.15.1', node: ^18.17.0 || ^20.3.0 || >=21.0.0} + requiresBuild: true + dependencies: + color: 4.2.3 + detect-libc: 2.0.2 + semver: 7.5.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.33.2 + '@img/sharp-darwin-x64': 0.33.2 + '@img/sharp-libvips-darwin-arm64': 1.0.1 + '@img/sharp-libvips-darwin-x64': 1.0.1 + '@img/sharp-libvips-linux-arm': 1.0.1 + '@img/sharp-libvips-linux-arm64': 1.0.1 + '@img/sharp-libvips-linux-s390x': 1.0.1 + '@img/sharp-libvips-linux-x64': 1.0.1 + '@img/sharp-libvips-linuxmusl-arm64': 1.0.1 + '@img/sharp-libvips-linuxmusl-x64': 1.0.1 + '@img/sharp-linux-arm': 0.33.2 + '@img/sharp-linux-arm64': 0.33.2 + '@img/sharp-linux-s390x': 0.33.2 + '@img/sharp-linux-x64': 0.33.2 + '@img/sharp-linuxmusl-arm64': 0.33.2 + '@img/sharp-linuxmusl-x64': 0.33.2 + '@img/sharp-wasm32': 0.33.2 + '@img/sharp-win32-ia32': 0.33.2 + '@img/sharp-win32-x64': 0.33.2 + dev: true + /shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1445,6 +1714,12 @@ packages: '@types/react': 18.2.47 dev: false + /simple-swizzle@0.2.2: + resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + dependencies: + is-arrayish: 0.3.2 + dev: true + /slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} diff --git a/src/downloader/images.ts b/src/downloader/images.ts new file mode 100644 index 0000000..bd1c3ff --- /dev/null +++ b/src/downloader/images.ts @@ -0,0 +1,110 @@ +import type { APIAttachment, APIMessage, Awaitable } from 'discord.js'; +import type { WebpOptions } from 'sharp'; +import { request } from 'undici'; +import debug from 'debug'; + +/** + * Callback used to save an image attachment. + * The returned string is the URL that will be used in the transcript. + * + * `undefined` indicates to use the original attachment URL. + * `null` indicates to not include the attachment in the transcript. + * `string` indicates to use the returned URL as the attachment URL (base64 or remote image). + */ +export type ResolveImageCallback = ( + attachment: APIAttachment, + message: APIMessage +) => Awaitable; + +/** + * Builder to build a image saving callback. + */ +export class TranscriptImageDownloader { + private static log = debug('discord-html-transcripts:TranscriptImageDownloader'); + private log = TranscriptImageDownloader.log; + + private maxFileSize?: number; // in kilobytes + private compression?: { + quality: number; // 1-100 + convertToWebP: boolean; + options: Omit; + }; + + /** + * Sets the maximum file size for *each* individual image. + * @param size The maximum file size in kilobytes + */ + withMaxSize(size: number) { + this.maxFileSize = size; + return this; + } + + /** + * Sets the compression quality for each image. This requires `sharp` to be installed. + * Optionally, images can be converted to WebP format which is smaller in size. + * @param quality The quality of the image (1 lowest - 100 highest). Lower quality means smaller file size. + * @param convertToWebP Whether to convert the image to WebP format + */ + withCompression(quality = 80, convertToWebP = false, options: Omit = {}) { + if (quality < 1 || quality > 100) throw new Error('Quality must be between 1 and 100'); + + // try and import sharp + import('sharp').catch((err) => { + console.error(err); + console.error( + `[discord-html-transcripts] Failed to import 'sharp'. Image compression requires the 'sharp' package to be installed. Either install sharp or remove the compression options.` + ); + }); + + this.compression = { quality, convertToWebP, options }; + return this; + } + + /** + * Builds the image saving callback. + */ + build(): ResolveImageCallback { + return async (attachment) => { + // if the attachment is not an image, return null + if (!attachment.width || !attachment.height) return undefined; + + // if the max file size is set, check if the file size is within the limit + if (this.maxFileSize && attachment.size > this.maxFileSize * 1024) return undefined; + + // fetch the image + this.log(`Fetching attachment ${attachment.id}: ${attachment.url}`); + const response = await request(attachment.url).catch((err) => { + console.error(`[discord-html-transcripts] Failed to download image for transcript: `, err); + return null; + }); + + if (!response) return undefined; + + const mimetype = response.headers['content-type']; + const buffer = await response.body.arrayBuffer().then((res) => Buffer.from(res)); + this.log(`Finished fetching ${attachment.id} (${buffer.length} bytes)`); + + // if the compression options are set, compress the image + if (this.compression) { + const sharp = await import('sharp'); + + this.log(`Compressing ${attachment.id} with 'sharp'`); + const sharpbuf = await sharp + .default(buffer) + .webp({ + quality: this.compression.quality, + force: this.compression.convertToWebP, + effort: 2, + ...this.compression.options, + }) + .toBuffer({ resolveWithObject: true }); + this.log(`Finished compressing ${attachment.id} (${sharpbuf.info.size} bytes)`); + + return `data:image/${sharpbuf.info.format};base64,${sharpbuf.data.toString('base64')}`; + } + + // return the base64 string + return `data:${mimetype};base64,${buffer.toString('base64')}`; + }; + } +} diff --git a/src/generator/index.tsx b/src/generator/index.tsx index 14ae04c..402d417 100644 --- a/src/generator/index.tsx +++ b/src/generator/index.tsx @@ -8,6 +8,7 @@ import path from 'path'; import { renderToString } from '@derockdev/discord-components-core/hydrate'; import { streamToString } from '../utils/utils'; import DiscordMessages from './transcript'; +import type { ResolveImageCallback } from '../downloader/images'; // read the package.json file and get the @derockdev/discord-components-core version let discordComponentsVersion = '^3.6.1'; @@ -24,6 +25,7 @@ export type RenderMessageContext = { channel: Channel; callbacks: { + resolveImageSrc: ResolveImageCallback; resolveChannel: (channelId: string) => Awaitable; resolveUser: (userId: string) => Awaitable; resolveRole: (roleId: string) => Awaitable; diff --git a/src/generator/renderers/attachment.tsx b/src/generator/renderers/attachment.tsx index adfde25..b63fed1 100644 --- a/src/generator/renderers/attachment.tsx +++ b/src/generator/renderers/attachment.tsx @@ -1,9 +1,9 @@ import { DiscordAttachment, DiscordAttachments } from '@derockdev/discord-components-react'; import React from 'react'; -import type { Attachment as AttachmentType, Message } from 'discord.js'; +import type { APIAttachment, APIMessage, Attachment as AttachmentType, Message } from 'discord.js'; import type { RenderMessageContext } from '..'; import type { AttachmentTypes } from '../../types'; -import { downloadImageToDataURL, formatBytes } from '../../utils/utils'; +import { formatBytes } from '../../utils/utils'; /** * Renders all attachments for a message @@ -17,7 +17,7 @@ export async function Attachments(props: { message: Message; context: RenderMess return ( {props.message.attachments.map((attachment, id) => ( - + ))} ); @@ -37,9 +37,11 @@ function getAttachmentType(attachment: AttachmentType): AttachmentTypes { export async function Attachment({ attachment, context, + message, }: { attachment: AttachmentType; context: RenderMessageContext; + message: Message; }) { let url = attachment.url; const name = attachment.name; @@ -49,10 +51,14 @@ export async function Attachment({ const type = getAttachmentType(attachment); // if the attachment is an image, download it to a data url - if (context.saveImages && type === 'image') { - const downloaded = await downloadImageToDataURL(url); - if (downloaded) { - url = downloaded; + if (type === 'image') { + const downloaded = await context.callbacks.resolveImageSrc( + attachment.toJSON() as APIAttachment, + message.toJSON() as APIMessage + ); + + if (downloaded !== null) { + url = downloaded ?? url; } } diff --git a/src/generator/transcript.tsx b/src/generator/transcript.tsx index c1d64e3..5fc42cb 100644 --- a/src/generator/transcript.tsx +++ b/src/generator/transcript.tsx @@ -1,6 +1,6 @@ import { DiscordHeader, DiscordMessages as DiscordMessagesComponent } from '@derockdev/discord-components-react'; import { ChannelType } from 'discord.js'; -import React, { Suspense } from 'react'; +import React from 'react'; import type { RenderMessageContext } from '.'; import MessageContent, { RenderType } from './renderers/content'; import DiscordMessage from './renderers/message'; @@ -46,11 +46,9 @@ export default async function DiscordMessages({ messages, channel, callbacks, .. {/* body */} - - {messages.map((message) => ( - - ))} - + {messages.map((message) => ( + + ))} {/* footer */}
diff --git a/src/index.ts b/src/index.ts index ecfd504..0591c18 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,9 +6,11 @@ import { type GenerateFromMessagesOptions, type ObjectType, } from './types'; +import { TranscriptImageDownloader, type ResolveImageCallback } from './downloader/images'; -// re-export component for custom rendering +// re-exports export { default as DiscordMessages } from './generator/transcript'; +export { TranscriptImageDownloader } from './downloader/images'; // version check const versionPrefix = version.split('.')[0]; @@ -36,7 +38,18 @@ export async function generateFromMessages attachment.url); + if (options.saveImages) { + if (options.callbacks?.resolveImageSrc) { + console.warn( + `[discord-html-transcripts] You have specified both saveImages and resolveImageSrc, please only specify one. resolveImageSrc will be used.` + ); + } else { + resolveImageSrc = new TranscriptImageDownloader().build(); + console.log('Using default downloader'); + } + } // render the messages const html = await DiscordMessages({ @@ -44,6 +57,7 @@ export async function generateFromMessages channel.client.channels.fetch(id).catch(() => null), resolveUser: async (id) => channel.client.users.fetch(id).catch(() => null), resolveRole: channel.isDMBased() ? () => null : async (id) => channel.guild?.roles.fetch(id).catch(() => null), diff --git a/src/types.ts b/src/types.ts index 0cc2c53..d5dff8e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,7 +31,7 @@ export type GenerateFromMessagesOptions = Partial<{ /** * Callbacks for resolving channels, users, and roles */ - callbacks: RenderMessageContext['callbacks']; + callbacks: Partial; /** * The name of the file to return if returnType is ExportReturnType.ATTACHMENT diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 4e09860..b75a414 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,5 +1,4 @@ import type { APIMessageComponentEmoji, Emoji } from 'discord.js'; -import { request } from 'undici'; import twemoji from 'twemoji'; export function isDefined(value: T | undefined | null): value is T { @@ -32,28 +31,6 @@ export function parseDiscordEmoji(emoji: Emoji | APIMessageComponentEmoji) { return `https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/${codepoints}.svg`; } -export async function downloadImageToDataURL(url: string): Promise { - const response = await request(url); - - const dataURL = await response.body - .arrayBuffer() - .then((res) => { - const data = Buffer.from(res).toString('base64'); - const mime = response.headers['content-type']; - - return `data:${mime};base64,${data}`; - }) - .catch((err) => { - if (!process.env.HIDE_TRANSCRIPT_ERRORS) { - console.error(`[discord-html-transcripts] Failed to download image for transcript: `, err); - } - - return null; - }); - - return dataURL; -} - /** * Converts a stream to a string * @param stream - The stream to convert diff --git a/tests/generate.ts b/tests/generate.ts index 97edb72..e3ae50a 100644 --- a/tests/generate.ts +++ b/tests/generate.ts @@ -22,8 +22,7 @@ client.on('ready', async () => { console.time('transcript'); const attachment = await createTranscript(channel, { - // Filter bot messages - filter: (message) => !message.author.bot, + // options go here }); console.timeEnd('transcript');