@hed-hog/core 0.0.400 → 0.0.401

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 (203) hide show
  1. package/dist/ai/ai.controller.d.ts +2 -0
  2. package/dist/ai/ai.controller.d.ts.map +1 -1
  3. package/dist/ai/ai.service.d.ts +86 -2
  4. package/dist/ai/ai.service.d.ts.map +1 -1
  5. package/dist/ai/ai.service.js +304 -23
  6. package/dist/ai/ai.service.js.map +1 -1
  7. package/dist/ai-instruction/ai-prompt-resolver.service.d.ts +12 -0
  8. package/dist/ai-instruction/ai-prompt-resolver.service.d.ts.map +1 -1
  9. package/dist/ai-instruction/ai-prompt-resolver.service.js +26 -0
  10. package/dist/ai-instruction/ai-prompt-resolver.service.js.map +1 -1
  11. package/dist/core.module.d.ts.map +1 -1
  12. package/dist/core.module.js +3 -0
  13. package/dist/core.module.js.map +1 -1
  14. package/dist/document-kit/document-kit.module.d.ts +3 -0
  15. package/dist/document-kit/document-kit.module.d.ts.map +1 -0
  16. package/dist/document-kit/document-kit.module.js +24 -0
  17. package/dist/document-kit/document-kit.module.js.map +1 -0
  18. package/dist/document-kit/document-kit.registry.d.ts +55 -0
  19. package/dist/document-kit/document-kit.registry.d.ts.map +1 -0
  20. package/dist/document-kit/document-kit.registry.js +91 -0
  21. package/dist/document-kit/document-kit.registry.js.map +1 -0
  22. package/dist/document-kit/document-kit.service.d.ts +49 -0
  23. package/dist/document-kit/document-kit.service.d.ts.map +1 -0
  24. package/dist/document-kit/document-kit.service.js +135 -0
  25. package/dist/document-kit/document-kit.service.js.map +1 -0
  26. package/dist/file/file.service.d.ts +15 -1
  27. package/dist/file/file.service.d.ts.map +1 -1
  28. package/dist/file/file.service.js +52 -24
  29. package/dist/file/file.service.js.map +1 -1
  30. package/dist/file/mcp-file-source.schema.d.ts.map +1 -1
  31. package/dist/file/mcp-file-source.schema.js +8 -5
  32. package/dist/file/mcp-file-source.schema.js.map +1 -1
  33. package/dist/index.d.ts +5 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +5 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
  38. package/dist/integration-profile/integration-profile.secrets.js +21 -0
  39. package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
  40. package/dist/integration-profile/testers/ai.testers.d.ts.map +1 -1
  41. package/dist/integration-profile/testers/ai.testers.js +46 -8
  42. package/dist/integration-profile/testers/ai.testers.js.map +1 -1
  43. package/dist/mcp/mcp.module.d.ts.map +1 -1
  44. package/dist/mcp/mcp.module.js +2 -0
  45. package/dist/mcp/mcp.module.js.map +1 -1
  46. package/dist/mcp/tools/core-access-log.mcp-tools.d.ts +17 -0
  47. package/dist/mcp/tools/core-access-log.mcp-tools.d.ts.map +1 -0
  48. package/dist/mcp/tools/core-access-log.mcp-tools.js +74 -0
  49. package/dist/mcp/tools/core-access-log.mcp-tools.js.map +1 -0
  50. package/dist/mcp/tools/core-files.mcp-tools.js +4 -4
  51. package/dist/mcp/tools/core-files.mcp-tools.js.map +1 -1
  52. package/dist/meeting/meeting-provider.registry.d.ts +72 -0
  53. package/dist/meeting/meeting-provider.registry.d.ts.map +1 -1
  54. package/dist/meeting/meeting-provider.registry.js.map +1 -1
  55. package/dist/oauth/oauth-token-broker.service.d.ts +50 -1
  56. package/dist/oauth/oauth-token-broker.service.d.ts.map +1 -1
  57. package/dist/oauth/oauth-token-broker.service.js +105 -3
  58. package/dist/oauth/oauth-token-broker.service.js.map +1 -1
  59. package/dist/oauth/providers/microsoft-entra-id.provider.d.ts +6 -0
  60. package/dist/oauth/providers/microsoft-entra-id.provider.d.ts.map +1 -1
  61. package/dist/oauth/providers/microsoft-entra-id.provider.js +30 -4
  62. package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
  63. package/dist/pdf/document/charts/chart-bar.d.ts +24 -0
  64. package/dist/pdf/document/charts/chart-bar.d.ts.map +1 -0
  65. package/dist/pdf/document/charts/chart-bar.js +117 -0
  66. package/dist/pdf/document/charts/chart-bar.js.map +1 -0
  67. package/dist/pdf/document/charts/chart-donut.d.ts +16 -0
  68. package/dist/pdf/document/charts/chart-donut.d.ts.map +1 -0
  69. package/dist/pdf/document/charts/chart-donut.js +89 -0
  70. package/dist/pdf/document/charts/chart-donut.js.map +1 -0
  71. package/dist/pdf/document/charts/chart-gauge.d.ts +15 -0
  72. package/dist/pdf/document/charts/chart-gauge.d.ts.map +1 -0
  73. package/dist/pdf/document/charts/chart-gauge.js +60 -0
  74. package/dist/pdf/document/charts/chart-gauge.js.map +1 -0
  75. package/dist/pdf/document/charts/chart-line.d.ts +29 -0
  76. package/dist/pdf/document/charts/chart-line.d.ts.map +1 -0
  77. package/dist/pdf/document/charts/chart-line.js +89 -0
  78. package/dist/pdf/document/charts/chart-line.js.map +1 -0
  79. package/dist/pdf/document/charts/chart-primitives.d.ts +64 -0
  80. package/dist/pdf/document/charts/chart-primitives.d.ts.map +1 -0
  81. package/dist/pdf/document/charts/chart-primitives.js +110 -0
  82. package/dist/pdf/document/charts/chart-primitives.js.map +1 -0
  83. package/dist/pdf/document/charts/chart-radar.d.ts +13 -0
  84. package/dist/pdf/document/charts/chart-radar.d.ts.map +1 -0
  85. package/dist/pdf/document/charts/chart-radar.js +83 -0
  86. package/dist/pdf/document/charts/chart-radar.js.map +1 -0
  87. package/dist/pdf/document/charts/chart-scale.d.ts +39 -0
  88. package/dist/pdf/document/charts/chart-scale.d.ts.map +1 -0
  89. package/dist/pdf/document/charts/chart-scale.js +118 -0
  90. package/dist/pdf/document/charts/chart-scale.js.map +1 -0
  91. package/dist/pdf/document/charts/index.d.ts +8 -0
  92. package/dist/pdf/document/charts/index.d.ts.map +1 -0
  93. package/dist/pdf/document/charts/index.js +24 -0
  94. package/dist/pdf/document/charts/index.js.map +1 -0
  95. package/dist/pdf/document/index.d.ts +8 -0
  96. package/dist/pdf/document/index.d.ts.map +1 -0
  97. package/dist/pdf/document/index.js +24 -0
  98. package/dist/pdf/document/index.js.map +1 -0
  99. package/dist/pdf/document/pdf-document.chrome.d.ts +20 -0
  100. package/dist/pdf/document/pdf-document.chrome.d.ts.map +1 -0
  101. package/dist/pdf/document/pdf-document.chrome.js +36 -0
  102. package/dist/pdf/document/pdf-document.chrome.js.map +1 -0
  103. package/dist/pdf/document/pdf-document.css.d.ts +20 -0
  104. package/dist/pdf/document/pdf-document.css.d.ts.map +1 -0
  105. package/dist/pdf/document/pdf-document.css.js +239 -0
  106. package/dist/pdf/document/pdf-document.css.js.map +1 -0
  107. package/dist/pdf/document/pdf-document.renderer.d.ts +10 -0
  108. package/dist/pdf/document/pdf-document.renderer.d.ts.map +1 -0
  109. package/dist/pdf/document/pdf-document.renderer.js +153 -0
  110. package/dist/pdf/document/pdf-document.renderer.js.map +1 -0
  111. package/dist/pdf/document/pdf-document.service.d.ts +27 -0
  112. package/dist/pdf/document/pdf-document.service.d.ts.map +1 -0
  113. package/dist/pdf/document/pdf-document.service.js +81 -0
  114. package/dist/pdf/document/pdf-document.service.js.map +1 -0
  115. package/dist/pdf/document/pdf-document.theme.d.ts +41 -0
  116. package/dist/pdf/document/pdf-document.theme.d.ts.map +1 -0
  117. package/dist/pdf/document/pdf-document.theme.js +105 -0
  118. package/dist/pdf/document/pdf-document.theme.js.map +1 -0
  119. package/dist/pdf/document/pdf-document.types.d.ts +108 -0
  120. package/dist/pdf/document/pdf-document.types.d.ts.map +1 -0
  121. package/dist/pdf/document/pdf-document.types.js +18 -0
  122. package/dist/pdf/document/pdf-document.types.js.map +1 -0
  123. package/dist/pdf/pdf.module.d.ts.map +1 -1
  124. package/dist/pdf/pdf.module.js +3 -2
  125. package/dist/pdf/pdf.module.js.map +1 -1
  126. package/dist/pdf/pdf.service.d.ts +69 -1
  127. package/dist/pdf/pdf.service.d.ts.map +1 -1
  128. package/dist/pdf/pdf.service.js +164 -63
  129. package/dist/pdf/pdf.service.js.map +1 -1
  130. package/dist/pdf/templates/commercial-proposal.template.d.ts +96 -0
  131. package/dist/pdf/templates/commercial-proposal.template.d.ts.map +1 -0
  132. package/dist/pdf/templates/commercial-proposal.template.js +276 -0
  133. package/dist/pdf/templates/commercial-proposal.template.js.map +1 -0
  134. package/dist/setting/favicon.controller.d.ts +26 -0
  135. package/dist/setting/favicon.controller.d.ts.map +1 -0
  136. package/dist/setting/favicon.controller.js +130 -0
  137. package/dist/setting/favicon.controller.js.map +1 -0
  138. package/dist/setting/setting.module.d.ts.map +1 -1
  139. package/dist/setting/setting.module.js +2 -1
  140. package/dist/setting/setting.module.js.map +1 -1
  141. package/dist/whatsapp-sender/whatsapp-sender.service.d.ts.map +1 -1
  142. package/dist/whatsapp-sender/whatsapp-sender.service.js +25 -2
  143. package/dist/whatsapp-sender/whatsapp-sender.service.js.map +1 -1
  144. package/hedhog/data/integration_type.yaml +6 -0
  145. package/hedhog/data/route.yaml +8 -0
  146. package/hedhog/data/setting_group.yaml +52 -0
  147. package/hedhog/frontend/messages/en.json +54 -1
  148. package/hedhog/frontend/messages/pt.json +54 -1
  149. package/package.json +4 -4
  150. package/src/ai/ai-attachment.fragments.spec.ts +56 -3
  151. package/src/ai/ai.service.responses.spec.ts +429 -0
  152. package/src/ai/ai.service.ts +409 -27
  153. package/src/ai-instruction/ai-prompt-resolver.service.spec.ts +44 -0
  154. package/src/ai-instruction/ai-prompt-resolver.service.ts +29 -0
  155. package/src/core.module.ts +3 -0
  156. package/src/document-kit/document-kit.module.ts +12 -0
  157. package/src/document-kit/document-kit.registry.ts +125 -0
  158. package/src/document-kit/document-kit.service.spec.ts +156 -0
  159. package/src/document-kit/document-kit.service.ts +173 -0
  160. package/src/file/file.service.spec.ts +168 -1
  161. package/src/file/file.service.ts +72 -34
  162. package/src/file/mcp-file-source.schema.ts +8 -5
  163. package/src/index.ts +5 -0
  164. package/src/integration-profile/integration-profile.secrets.ts +21 -0
  165. package/src/integration-profile/testers/ai.testers.ts +70 -5
  166. package/src/integration-profile/testers/remaining.testers.spec.ts +65 -1
  167. package/src/integration-profile/testers/tester-coverage.spec.ts +10 -0
  168. package/src/mcp/mcp.module.ts +2 -0
  169. package/src/mcp/tools/core-access-log.mcp-tools.ts +66 -0
  170. package/src/mcp/tools/core-files.mcp-tools.ts +4 -4
  171. package/src/meeting/meeting-provider.registry.ts +80 -0
  172. package/src/oauth/oauth-token-broker.service.spec.ts +108 -0
  173. package/src/oauth/oauth-token-broker.service.ts +129 -2
  174. package/src/oauth/providers/microsoft-entra-id.provider.spec.ts +133 -0
  175. package/src/oauth/providers/microsoft-entra-id.provider.ts +29 -2
  176. package/src/pdf/document/charts/chart-bar.ts +217 -0
  177. package/src/pdf/document/charts/chart-donut.ts +163 -0
  178. package/src/pdf/document/charts/chart-gauge.ts +94 -0
  179. package/src/pdf/document/charts/chart-line.ts +159 -0
  180. package/src/pdf/document/charts/chart-primitives.ts +188 -0
  181. package/src/pdf/document/charts/chart-radar.ts +136 -0
  182. package/src/pdf/document/charts/chart-scale.spec.ts +93 -0
  183. package/src/pdf/document/charts/chart-scale.ts +141 -0
  184. package/src/pdf/document/charts/charts.spec.ts +157 -0
  185. package/src/pdf/document/charts/index.ts +7 -0
  186. package/src/pdf/document/index.ts +7 -0
  187. package/src/pdf/document/pdf-document.chrome.ts +38 -0
  188. package/src/pdf/document/pdf-document.css.ts +238 -0
  189. package/src/pdf/document/pdf-document.renderer.spec.ts +129 -0
  190. package/src/pdf/document/pdf-document.renderer.ts +215 -0
  191. package/src/pdf/document/pdf-document.service.ts +77 -0
  192. package/src/pdf/document/pdf-document.theme.ts +146 -0
  193. package/src/pdf/document/pdf-document.types.ts +97 -0
  194. package/src/pdf/pdf.module.ts +3 -2
  195. package/src/pdf/pdf.service.spec.ts +131 -0
  196. package/src/pdf/pdf.service.ts +245 -82
  197. package/src/pdf/templates/commercial-proposal.template.spec.ts +177 -0
  198. package/src/pdf/templates/commercial-proposal.template.ts +400 -0
  199. package/src/setting/favicon.controller.spec.ts +92 -0
  200. package/src/setting/favicon.controller.ts +135 -0
  201. package/src/setting/setting.module.ts +2 -1
  202. package/src/whatsapp-sender/whatsapp-sender.service.spec.ts +73 -0
  203. package/src/whatsapp-sender/whatsapp-sender.service.ts +32 -2
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
2
- import { NotFoundException } from '@nestjs/common';
2
+ import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
3
3
  import { FileService } from './file.service';
4
4
  import { ProviderFactory } from './provider/provider.factory';
5
5
 
@@ -274,6 +274,80 @@ describe('FileService — arquivo privado nas rotas por id', () => {
274
274
  });
275
275
  });
276
276
 
277
+ /**
278
+ * Um 503 do Spaces com corpo que não é XML estourava no parse do SDK
279
+ * (`char 'u' is not expected`) e virava 500 na rota de segmento HLS. O SDK ainda
280
+ * carimba o status em `$metadata`, e é por ele que a falha vira 503 passageiro.
281
+ */
282
+ describe('FileService — falha do storage na leitura', () => {
283
+ const buildService = (readError: unknown, size: number | null = 1024) => {
284
+ const prisma = {
285
+ file: {
286
+ findUnique: jest.fn().mockResolvedValue({
287
+ id: 9,
288
+ filename: 'seg037.ts',
289
+ location: 'lms/lessons/hls/2274/stream_0',
290
+ path: 'lms/lessons/hls/2274/stream_0/abc.ts',
291
+ size,
292
+ file_mimetype: { name: 'video/MP2T' },
293
+ } as never),
294
+ },
295
+ };
296
+ const service = new FileService(prisma as any, {} as any, {} as any, {} as any, {} as any);
297
+ const provider = {
298
+ metaData: jest.fn().mockResolvedValue({ ContentLength: 1024 } as never),
299
+ readStream: jest.fn().mockRejectedValue(readError as never),
300
+ };
301
+ jest.spyOn(service, 'getProvider').mockResolvedValue(provider as never);
302
+ return { service, provider };
303
+ };
304
+
305
+ afterEach(() => jest.restoreAllMocks());
306
+
307
+ it('503 do storage com corpo ilegível vira ServiceUnavailable com code STORAGE_UNAVAILABLE', async () => {
308
+ const sdkError = Object.assign(new Error("char 'u' is not expected.:1:1"), {
309
+ $metadata: { httpStatusCode: 503 },
310
+ });
311
+ const { service } = buildService(sdkError);
312
+
313
+ const erro: any = await service.openReadStreamById(9).catch((e) => e);
314
+
315
+ expect(erro).toBeInstanceOf(ServiceUnavailableException);
316
+ expect(erro.getResponse()).toMatchObject({ code: 'STORAGE_UNAVAILABLE' });
317
+ expect(erro.cause).toBe(sdkError);
318
+ });
319
+
320
+ it('throttle (SlowDown) também é indisponibilidade, inclusive na leitura por faixa', async () => {
321
+ const { service } = buildService(Object.assign(new Error('Slow down'), { name: 'SlowDown' }));
322
+
323
+ await expect(service.openReadStreamWithRange(9, 0, 99)).rejects.toBeInstanceOf(
324
+ ServiceUnavailableException,
325
+ );
326
+ });
327
+
328
+ it('NoSuchKey continua 404', async () => {
329
+ const { service } = buildService(Object.assign(new Error('missing'), { name: 'NoSuchKey' }));
330
+
331
+ await expect(service.openReadStreamById(9)).rejects.toBeInstanceOf(NotFoundException);
332
+ });
333
+
334
+ it('erro sem status de storage é repassado como veio', async () => {
335
+ const original = new Error('boom');
336
+ const { service } = buildService(original);
337
+
338
+ await expect(service.openReadStreamById(9)).rejects.toBe(original);
339
+ });
340
+
341
+ it('não faz HEAD no storage quando o tamanho já está na tabela', async () => {
342
+ const { service, provider } = buildService(null);
343
+ provider.readStream.mockResolvedValue({} as never);
344
+
345
+ await service.openReadStreamById(9);
346
+
347
+ expect(provider.metaData).not.toHaveBeenCalled();
348
+ });
349
+ });
350
+
277
351
  /**
278
352
  * A varredura de reotimização do acervo passa em todo arquivo, e recomprimir WebP sobre
279
353
  * WebP sempre devolve alguns bytes a menos. Sem um ganho mínimo, cada passada trocaria o
@@ -367,3 +441,96 @@ describe('FileService.reoptimizeFile — ganho mínimo', () => {
367
441
  expect(result.mimetypeChanged).toBe(true);
368
442
  });
369
443
  });
444
+
445
+ /**
446
+ * O Instagram recusa WebP, e toda image/* que passa pela otimizacao vira WebP. Quem
447
+ * entrega o arquivo a um consumidor externo pede os bytes intactos.
448
+ */
449
+ describe('FileService — skipImageOptimization', () => {
450
+ const makeService = () => {
451
+ const deps: any = {
452
+ prisma: { file: { create: jest.fn().mockImplementation((args: any) => Promise.resolve(args.data)) } },
453
+ pagination: {},
454
+ setting: {},
455
+ jwt: {},
456
+ imageOptimization: {
457
+ optimizeBuffer: jest.fn().mockResolvedValue({
458
+ buffer: Buffer.from('webp'),
459
+ mimetype: 'image/webp',
460
+ originalname: 'foto.webp',
461
+ } as never),
462
+ },
463
+ };
464
+ const service = new FileService(
465
+ deps.prisma,
466
+ deps.pagination,
467
+ deps.setting,
468
+ deps.jwt,
469
+ deps.imageOptimization,
470
+ );
471
+ const provider = {
472
+ upload: jest.fn().mockResolvedValue('social/foto.jpg' as never),
473
+ uploadFromPath: jest.fn().mockResolvedValue('social/foto.jpg' as never),
474
+ };
475
+ jest.spyOn(service as any, 'getProvider').mockResolvedValue(provider as never);
476
+ jest.spyOn(service as any, 'getMimeType').mockResolvedValue(7 as never);
477
+ return { service, deps, provider };
478
+ };
479
+
480
+ it('upload grava a imagem sem otimizar quando o chamador pede', async () => {
481
+ const { service, deps, provider } = makeService();
482
+ const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3]);
483
+
484
+ const file: any = await service.upload(
485
+ 'social/brands/1',
486
+ { buffer: jpeg, mimetype: 'image/jpeg', originalname: 'foto.jpg', size: jpeg.length } as any,
487
+ undefined,
488
+ { skipMimeValidation: true, skipSizeValidation: true, skipImageOptimization: true },
489
+ );
490
+
491
+ expect(deps.imageOptimization.optimizeBuffer).not.toHaveBeenCalled();
492
+ const sent = (provider.upload.mock.calls[0] as any[])[1];
493
+ expect(sent.mimetype).toBe('image/jpeg');
494
+ expect(Buffer.compare(sent.buffer, jpeg)).toBe(0);
495
+ expect(file.size).toBe(jpeg.length);
496
+ });
497
+
498
+ it('upload sem a opcao continua otimizando', async () => {
499
+ const { service, deps } = makeService();
500
+ const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3]);
501
+
502
+ await service.upload(
503
+ 'social/brands/1',
504
+ { buffer: jpeg, mimetype: 'image/jpeg', originalname: 'foto.jpg', size: jpeg.length } as any,
505
+ undefined,
506
+ { skipMimeValidation: true, skipSizeValidation: true },
507
+ );
508
+
509
+ expect(deps.imageOptimization.optimizeBuffer).toHaveBeenCalledTimes(1);
510
+ });
511
+
512
+ it('uploadFromPath manda a imagem pelo caminho de streaming, intacta', async () => {
513
+ const { service, deps, provider } = makeService();
514
+ const os = await import('node:os');
515
+ const fs = await import('node:fs/promises');
516
+ const path = await import('node:path');
517
+ const tmp = path.join(os.tmpdir(), `skip-opt-${process.pid}.jpg`);
518
+ await fs.writeFile(tmp, Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3]));
519
+
520
+ try {
521
+ await service.uploadFromPath('social/brands/1', tmp, {
522
+ mimetype: 'image/jpeg',
523
+ originalname: 'foto.jpg',
524
+ skipMimeValidation: true,
525
+ skipSizeValidation: true,
526
+ skipImageOptimization: true,
527
+ });
528
+ } finally {
529
+ await fs.rm(tmp, { force: true });
530
+ }
531
+
532
+ expect(deps.imageOptimization.optimizeBuffer).not.toHaveBeenCalled();
533
+ expect(provider.uploadFromPath).toHaveBeenCalledTimes(1);
534
+ expect(provider.upload).not.toHaveBeenCalled();
535
+ });
536
+ });
@@ -31,7 +31,8 @@ import {
31
31
  Injectable,
32
32
  Logger,
33
33
  NotFoundException,
34
- OnModuleInit
34
+ OnModuleInit,
35
+ ServiceUnavailableException
35
36
  } from '@nestjs/common';
36
37
  import { JwtService } from '@nestjs/jwt';
37
38
  import { createHash } from 'crypto';
@@ -60,6 +61,9 @@ import {
60
61
  } from './provider/abstract.provider';
61
62
  import { ProviderFactory } from './provider/provider.factory';
62
63
 
64
+ /** Códigos de erro do S3/Spaces que indicam indisponibilidade passageira, não arquivo ruim. */
65
+ const STORAGE_UNAVAILABLE_CODES = ['SlowDown', 'ServiceUnavailable', 'InternalError', 'RequestTimeout'];
66
+
63
67
  type ResolvedProvider = {
64
68
  provider: AbstractProvider;
65
69
  providerId: number;
@@ -304,16 +308,37 @@ export class FileService implements OnModuleInit {
304
308
  return file;
305
309
  }
306
310
 
307
- private mapStorageNotFoundError(error: any, fileId: number) {
308
- const missingInStorage =
309
- error?.name === 'NoSuchKey' ||
310
- error?.Code === 'NoSuchKey' ||
311
- error?.$metadata?.httpStatusCode === 404;
311
+ /**
312
+ * Traduz a falha do provider de storage para a resposta HTTP certa.
313
+ *
314
+ * 5xx e throttle do S3/Spaces são indisponibilidade passageira, não defeito:
315
+ * viram 503 com `code: STORAGE_UNAVAILABLE`, que o HttpExceptionFilter da API
316
+ * responde com `Retry-After` e reporta deduplicado. Sem isso, um 503 do Spaces
317
+ * com corpo que não é XML (ex.: `upstream connect error` do balanceador)
318
+ * estourava no parse do SDK como `char 'u' is not expected` e virava 500.
319
+ *
320
+ * O status vem de `$metadata.httpStatusCode`, que o SDK carimba mesmo quando a
321
+ * desserialização do erro falha; `$response.statusCode` cobre o resto.
322
+ */
323
+ private mapStorageError(error: any, fileId: number): never {
324
+ const status = error?.$metadata?.httpStatusCode ?? error?.$response?.statusCode;
325
+ const codes = [error?.name, error?.Code];
312
326
 
313
- if (missingInStorage) {
327
+ if (codes.includes('NoSuchKey') || status === 404) {
314
328
  throw new NotFoundException(`File not found in storage: ${fileId}`);
315
329
  }
316
330
 
331
+ const unavailable =
332
+ (typeof status === 'number' && status >= 500) ||
333
+ codes.some((c) => STORAGE_UNAVAILABLE_CODES.includes(c));
334
+
335
+ if (unavailable) {
336
+ throw new ServiceUnavailableException(
337
+ { message: 'Storage temporarily unavailable', code: 'STORAGE_UNAVAILABLE' },
338
+ { cause: error },
339
+ );
340
+ }
341
+
317
342
  throw error;
318
343
  }
319
344
 
@@ -338,11 +363,15 @@ export class FileService implements OnModuleInit {
338
363
  const provider = await this.getProvider();
339
364
  const providerPath = this.resolveProviderPath(file);
340
365
 
366
+ // O HEAD só serve para descobrir o tamanho quando a tabela não o tem. Feito
367
+ // sempre, dobrava as requisições ao storage em cada segmento HLS.
341
368
  let metadata: any;
342
- try {
343
- metadata = await provider.metaData(providerPath);
344
- } catch {
345
- metadata = undefined;
369
+ if (file.size == null) {
370
+ try {
371
+ metadata = await provider.metaData(providerPath);
372
+ } catch {
373
+ metadata = undefined;
374
+ }
346
375
  }
347
376
 
348
377
  try {
@@ -353,7 +382,7 @@ export class FileService implements OnModuleInit {
353
382
  size: file.size ?? this.getFileSizeFromMetadata(metadata),
354
383
  };
355
384
  } catch (error) {
356
- this.mapStorageNotFoundError(error, fileId);
385
+ this.mapStorageError(error, fileId);
357
386
  }
358
387
  }
359
388
 
@@ -375,10 +404,15 @@ export class FileService implements OnModuleInit {
375
404
  const resolvedEnd = end !== undefined ? end : totalSize > 0 ? totalSize - 1 : undefined;
376
405
  const hasRange = start !== undefined;
377
406
 
378
- const stream = await provider.readStream(
379
- providerPath,
380
- hasRange ? { start: resolvedStart, end: resolvedEnd } : undefined,
381
- );
407
+ let stream: Awaited<ReturnType<AbstractProvider['readStream']>>;
408
+ try {
409
+ stream = await provider.readStream(
410
+ providerPath,
411
+ hasRange ? { start: resolvedStart, end: resolvedEnd } : undefined,
412
+ );
413
+ } catch (error) {
414
+ this.mapStorageError(error, fileId);
415
+ }
382
416
 
383
417
  const contentLength =
384
418
  resolvedEnd !== undefined ? resolvedEnd - resolvedStart + 1 : totalSize;
@@ -461,16 +495,7 @@ export class FileService implements OnModuleInit {
461
495
  try {
462
496
  providerBuffer = await provider.buffer(providerPath);
463
497
  } catch (error: any) {
464
- const missingInStorage =
465
- error?.name === 'NoSuchKey' ||
466
- error?.Code === 'NoSuchKey' ||
467
- error?.$metadata?.httpStatusCode === 404;
468
-
469
- if (missingInStorage) {
470
- throw new NotFoundException(`File not found in storage: ${fileId}`);
471
- }
472
-
473
- throw error;
498
+ this.mapStorageError(error, fileId);
474
499
  }
475
500
 
476
501
  const buffer = Buffer.from(providerBuffer);
@@ -549,6 +574,10 @@ export class FileService implements OnModuleInit {
549
574
  // Quem enviou. É o que permite recusar um `fileId` de outro usuário nas tools MCP
550
575
  // (ver `assertFileUsable`); fica nulo nos caminhos internos sem usuário.
551
576
  createdByUserId?: number | null;
577
+ // Grava a imagem byte a byte, sem a conversão para WebP nem o redimensionamento.
578
+ // Para quem entrega o arquivo a um consumidor externo que exige o formato
579
+ // original — o Instagram recusa WebP. O chamador já normalizou o arquivo.
580
+ skipImageOptimization?: boolean;
552
581
  },
553
582
  ) {
554
583
 
@@ -585,12 +614,18 @@ export class FileService implements OnModuleInit {
585
614
  fileBuffer.buffer,
586
615
  );
587
616
 
588
- const optimized = await this.imageOptimizationService.optimizeBuffer(
589
- fileBuffer.buffer,
590
- effectiveMimetype,
591
- normalizedFilename,
592
- { maxWidth: resize?.maxWidth, maxHeight: resize?.maxHeight },
593
- );
617
+ const optimized = options?.skipImageOptimization
618
+ ? {
619
+ buffer: fileBuffer.buffer,
620
+ mimetype: effectiveMimetype,
621
+ originalname: normalizedFilename,
622
+ }
623
+ : await this.imageOptimizationService.optimizeBuffer(
624
+ fileBuffer.buffer,
625
+ effectiveMimetype,
626
+ normalizedFilename,
627
+ { maxWidth: resize?.maxWidth, maxHeight: resize?.maxHeight },
628
+ );
594
629
 
595
630
  const normalizedFile = {
596
631
  ...fileBuffer,
@@ -635,6 +670,8 @@ export class FileService implements OnModuleInit {
635
670
  createdByUserId?: number | null;
636
671
  // Mesmo par maxWidth/maxHeight que `upload` aceita — só tem efeito em image/*.
637
672
  resize?: { maxWidth?: number; maxHeight?: number };
673
+ // Mesma opção de `upload`: a imagem segue o caminho de streaming, intacta.
674
+ skipImageOptimization?: boolean;
638
675
  },
639
676
  ) {
640
677
  if (!filePath) {
@@ -684,8 +721,9 @@ export class FileService implements OnModuleInit {
684
721
  // streaming direto do disco via provider.uploadFromPath. Isso evita carregar
685
722
  // o arquivo inteiro na RAM (fs.readFile estoura o limite de Buffer / causa OOM)
686
723
  // e habilita upload multipart no S3. A otimização de imagem só se aplica a
687
- // image/*, então não há perda nesse caminho.
688
- if (!mimetype.startsWith('image/')) {
724
+ // image/*, então não há perda nesse caminho. `skipImageOptimization` manda a
725
+ // imagem pelo mesmo caminho: o chamador quer os bytes exatamente como estão.
726
+ if (!mimetype.startsWith('image/') || metadata?.skipImageOptimization) {
689
727
  const url = await provider.uploadFromPath(destination, filePath, {
690
728
  filename: originalname,
691
729
  mimetype,
@@ -35,7 +35,7 @@ export const FILE_SOURCE_SCHEMA_PROPERTIES: Record<string, any> = {
35
35
  uploadId: {
36
36
  type: 'number',
37
37
  description:
38
- 'ID returned by core.files.create-upload-url or core.files.upload.init, after the binary has been uploaded. Use this to send a file that is on disk without putting its bytes in the conversation.',
38
+ 'ID returned by core.files.create-upload-url (after the PUT) or core.files.upload.init (after every chunk was sent with core.files.upload.append). The upload is completed automatically, so core.files.upload.complete is optional here.',
39
39
  },
40
40
  fileUrl: {
41
41
  type: 'string',
@@ -45,7 +45,7 @@ export const FILE_SOURCE_SCHEMA_PROPERTIES: Record<string, any> = {
45
45
  fileBase64: {
46
46
  type: 'string',
47
47
  description:
48
- 'Base64-encoded content, sent inline. Only for small files (about 256kb): base64 of anything larger overflows the model output limit. Requires fileName and mimeType.',
48
+ 'Base64-encoded content, sent inline. Only for small files (about 256kb): base64 of anything larger overflows the model output limit; use core.files.upload.init for larger files instead of giving up. Requires fileName and mimeType.',
49
49
  },
50
50
  fileName: {
51
51
  type: 'string',
@@ -72,9 +72,12 @@ export const FILE_SOURCE_SCHEMA_PROPERTIES: Record<string, any> = {
72
72
  * `storage-max-size`, e é ele que aparece na mensagem de erro.
73
73
  */
74
74
  export const FILE_SOURCE_DESCRIPTION = [
75
- 'Exactly one file source is required: fileId, uploadId, fileUrl or fileBase64.',
76
- 'Prefer fileId; use fileBase64 only for files of a few hundred kilobytes, since the encoded content has to fit in a single tool call.',
77
- 'To send a local file without putting it in the conversation, call core.files.create-upload-url, PUT the binary to the URL it returns, call core.files.upload.complete, and pass the resulting fileId here.',
75
+ 'Exactly one file source is required: fileId, uploadId, fileUrl or fileBase64. Pick the first that fits:',
76
+ '(1) file already in storage: fileId.',
77
+ '(2) file publicly reachable on the internet: fileUrl.',
78
+ '(3) client can make HTTP requests to the Hub API host (e.g. a local CLI agent): core.files.create-upload-url, PUT the bytes to the returned url, then pass the uploadId (or the fileId from core.files.upload.complete). Fastest.',
79
+ '(4) client runs in a network-restricted sandbox (e.g. claude.ai), or the PUT fails with host_not_allowed, 403 or a network error: core.files.upload.init, then core.files.upload.append for each chunk (index 0..totalChunks-1, chunkBase64 of at most chunkBytes raw bytes as returned by init), then pass the uploadId (or the fileId from core.files.upload.complete). Nothing leaves the MCP connection, and it works up to the storage max size; generate the chunks programmatically in the sandbox (split the file, print the base64 of each chunk) instead of typing base64 by hand.',
80
+ '(5) small file (about 256kb or less): fileBase64 inline.',
78
81
  'Every path enforces the storage-accept-mimetype and storage-max-size settings (10mb by default).',
79
82
  ].join(' ');
80
83
 
package/src/index.ts CHANGED
@@ -33,6 +33,9 @@ export * from './mail-layout/mail-layout.service';
33
33
  export * from './message-template/message-template.module';
34
34
  export * from './message-template/message-template.registry';
35
35
  export * from './message-template/message-template.service';
36
+ export * from './document-kit/document-kit.module';
37
+ export * from './document-kit/document-kit.registry';
38
+ export * from './document-kit/document-kit.service';
36
39
  export * from './mail/mail.module';
37
40
  export * from './mail/mail.service';
38
41
 
@@ -57,8 +60,10 @@ export * from './file/mcp-file-source.schema';
57
60
  export * from './file/mimetype-sniff.util';
58
61
  export * from './file/remote-fetch.util';
59
62
 
63
+ export * from './pdf/document';
60
64
  export * from './pdf/pdf.module';
61
65
  export * from './pdf/pdf.service';
66
+ export * from './pdf/templates/commercial-proposal.template';
62
67
  export * from './setting/brand-asset.service';
63
68
  export * from './setting/setting.module';
64
69
  export * from './setting/setting.service';
@@ -76,6 +76,27 @@ export const INTEGRATION_SECRET_KEYS: Record<string, string[]> = {
76
76
  // uso, então ele é reescrito no perfil a cada renovação: se não estiver aqui,
77
77
  // cada renovação grava a credencial em texto puro no banco.
78
78
  'melhor-envio': ['access_token', 'client_secret', 'refresh_token'],
79
+ // Redes sociais
80
+ // O app_secret troca o code do OAuth por token de qualquer Pagina que autorize o
81
+ // app; o webhook_verify_token e comparado no handshake dos webhooks da Meta.
82
+ // Os tokens das contas conectadas NAO moram aqui (ficam cifrados em
83
+ // social_channel, uma linha por Pagina/conta).
84
+ 'meta-social': ['app_secret', 'webhook_verify_token'],
85
+ // App do Instagram (Instagram API with Instagram Login) — o segredo do app do
86
+ // INSTAGRAM, nao o do app da Meta. Troca o code do OAuth pelo token de
87
+ // qualquer conta que autorize o app; os tokens de 60 dias das contas ficam
88
+ // cifrados em social_channel.
89
+ 'instagram-social': ['app_secret'],
90
+ // Apps do Google para o modulo social (um projeto por rede). O client_secret
91
+ // troca o code do OAuth pelo refresh token de qualquer canal/local que
92
+ // autorize o app; os refresh tokens ficam cifrados em social_channel.
93
+ 'youtube-social': ['client_secret'],
94
+ 'google-business-social': ['client_secret'],
95
+ // App do LinkedIn para paginas de empresa (Community Management API). O
96
+ // client_secret troca o code do OAuth e renova o access token de qualquer
97
+ // pagina que autorize o app; as credenciais das paginas (envelope lir1:/lia1:)
98
+ // ficam cifradas em social_channel.
99
+ 'linkedin-social': ['client_secret'],
79
100
  };
80
101
 
81
102
  /** Chaves sensíveis de um provider (vazio se desconhecido). */
@@ -3,11 +3,45 @@ import {
3
3
  fail,
4
4
  pass,
5
5
  probeJson,
6
+ redactSecrets,
6
7
  requireFields,
7
8
  str,
9
+ type ProbeResult,
8
10
  } from './http-probe';
9
11
  import { IntegrationTester } from './integration-tester.types';
10
12
 
13
+ /**
14
+ * A frase do veredito mais o que o provider de fato respondeu.
15
+ *
16
+ * Sem isso todo 401 virava a mesma sentenca fixa e a resposta do provider —
17
+ * que o `probeJson` ja captura — era jogada fora. Foi o que fez um header de
18
+ * organizacao errado ser reportado como "chave invalida", mandando conferir o
19
+ * campo que estava certo.
20
+ *
21
+ * O resultado de um tester NAO passa por `sanitizeTesterError` (esse so cobre o
22
+ * caminho de excecao), entao a redacao dos segredos acontece aqui.
23
+ */
24
+ function withProviderDetail(
25
+ base: string,
26
+ result: ProbeResult,
27
+ secretValues: string[],
28
+ ): string {
29
+ const raw = String(result.body?.error?.message ?? '').trim();
30
+ if (!raw) return base;
31
+
32
+ const detail = redactSecrets(raw, secretValues).trim();
33
+ return detail ? `${base} ${detail}` : base;
34
+ }
35
+
36
+ /** O 401 da OpenAI culpa a chave ou a organizacao? A resposta dela ja diz. */
37
+ function isOpenAiOrganizationProblem(result: ProbeResult): boolean {
38
+ const code = String(result.body?.error?.code ?? '');
39
+ const message = String(result.body?.error?.message ?? '');
40
+ return (
41
+ code === 'invalid_organization' || /no such organization/i.test(message)
42
+ );
43
+ }
44
+
11
45
  /**
12
46
  * Testadores dos providers de IA e geração de vídeo.
13
47
  *
@@ -33,7 +67,26 @@ const openAiTester: IntegrationTester = {
33
67
  ctx.signal,
34
68
  );
35
69
 
36
- if (result.status === 401) return fail('Invalid OpenAI API key.');
70
+ if (result.status === 401) {
71
+ // O header de organizacao e opcional e so este testador o envia: o chat
72
+ // real nunca manda. Apontar o campo certo evita trocar uma chave boa.
73
+ if (isOpenAiOrganizationProblem(result)) {
74
+ return fail(
75
+ withProviderDetail(
76
+ organization
77
+ ? `OpenAI rejected the organization "${organization}". ` +
78
+ 'Check the Organization ID field (it looks like "org-..."), ' +
79
+ 'or leave it empty.'
80
+ : 'OpenAI rejected the organization.',
81
+ result,
82
+ ctx.secretValues,
83
+ ),
84
+ );
85
+ }
86
+ return fail(
87
+ withProviderDetail('Invalid OpenAI API key.', result, ctx.secretValues),
88
+ );
89
+ }
37
90
  if (result.status === 403) {
38
91
  return fail('This API key has no permission to list models.');
39
92
  }
@@ -72,7 +125,9 @@ const geminiTester: IntegrationTester = {
72
125
  const result = await probeJson(url, {}, ctx.signal);
73
126
 
74
127
  if (result.status === 400 || result.status === 401 || result.status === 403) {
75
- return fail('Invalid Gemini API key.');
128
+ return fail(
129
+ withProviderDetail('Invalid Gemini API key.', result, ctx.secretValues),
130
+ );
76
131
  }
77
132
  if (!result.ok) {
78
133
  return fail(describeHttpError(result, 'Google rejected the credentials.'));
@@ -102,7 +157,11 @@ const claudeTester: IntegrationTester = {
102
157
  ctx.signal,
103
158
  );
104
159
 
105
- if (result.status === 401) return fail('Invalid Anthropic API key.');
160
+ if (result.status === 401) {
161
+ return fail(
162
+ withProviderDetail('Invalid Anthropic API key.', result, ctx.secretValues),
163
+ );
164
+ }
106
165
  if (!result.ok) {
107
166
  return fail(describeHttpError(result, 'Anthropic rejected the credentials.'));
108
167
  }
@@ -124,7 +183,11 @@ const deepseekTester: IntegrationTester = {
124
183
  ctx.signal,
125
184
  );
126
185
 
127
- if (result.status === 401) return fail('Invalid DeepSeek API key.');
186
+ if (result.status === 401) {
187
+ return fail(
188
+ withProviderDetail('Invalid DeepSeek API key.', result, ctx.secretValues),
189
+ );
190
+ }
128
191
  if (!result.ok) {
129
192
  return fail(describeHttpError(result, 'DeepSeek rejected the credentials.'));
130
193
  }
@@ -147,7 +210,9 @@ const lumaTester: IntegrationTester = {
147
210
  );
148
211
 
149
212
  if (result.status === 401 || result.status === 403) {
150
- return fail('Invalid Luma API key.');
213
+ return fail(
214
+ withProviderDetail('Invalid Luma API key.', result, ctx.secretValues),
215
+ );
151
216
  }
152
217
  if (!result.ok) {
153
218
  return fail(describeHttpError(result, 'Luma rejected the credentials.'));
@@ -26,11 +26,14 @@ const all = [
26
26
  const bySlug = (slug: string): IntegrationTester =>
27
27
  all.find((tester) => tester.providerSlug === slug)!;
28
28
 
29
- const ctx = (): IntegrationTestContext => ({
29
+ const ctx = (
30
+ overrides: Partial<IntegrationTestContext> = {},
31
+ ): IntegrationTestContext => ({
30
32
  signal: AbortSignal.timeout(5_000),
31
33
  typeSlug: 'test',
32
34
  providerSlug: 'test',
33
35
  secretValues: [],
36
+ ...overrides,
34
37
  });
35
38
 
36
39
  function jsonResponse(body: unknown, status = 200) {
@@ -252,6 +255,67 @@ describe('openai tester', () => {
252
255
  error: 'Invalid OpenAI API key.',
253
256
  });
254
257
  });
258
+
259
+ // O header de organização é opcional e só este testador o envia. Um `org-...`
260
+ // errado devolve 401, e a mensagem fixa de "chave inválida" mandava conferir
261
+ // justamente o campo que estava certo.
262
+ it('culpa a organização, e não a chave, quando é ela que a OpenAI recusa', async () => {
263
+ fetchMock.mockResolvedValue(
264
+ jsonResponse(
265
+ {
266
+ error: {
267
+ message: 'No such organization: org-errada.',
268
+ code: 'invalid_organization',
269
+ },
270
+ },
271
+ 401,
272
+ ),
273
+ );
274
+
275
+ const outcome = await tester.test(
276
+ { api_key: 'sk-test', organization: 'org-errada' },
277
+ ctx(),
278
+ );
279
+
280
+ expect(outcome.ok).toBe(false);
281
+ expect(outcome.error).toContain('organization');
282
+ expect(outcome.error).toContain('org-errada');
283
+ expect(outcome.error).not.toContain('Invalid OpenAI API key');
284
+ });
285
+
286
+ it('acrescenta o motivo que a OpenAI deu quando a chave é mesmo o problema', async () => {
287
+ fetchMock.mockResolvedValue(
288
+ jsonResponse(
289
+ { error: { message: 'Incorrect API key provided.', code: 'invalid_api_key' } },
290
+ 401,
291
+ ),
292
+ );
293
+
294
+ const outcome = await tester.test({ api_key: 'sk-bad' }, ctx());
295
+
296
+ expect(outcome.error).toBe(
297
+ 'Invalid OpenAI API key. Incorrect API key provided.',
298
+ );
299
+ });
300
+
301
+ // O resultado de um tester não passa por `sanitizeTesterError`, então a
302
+ // redação do segredo tem de acontecer dentro do próprio testador.
303
+ it('não deixa a chave vazar pela mensagem do provider', async () => {
304
+ fetchMock.mockResolvedValue(
305
+ jsonResponse(
306
+ { error: { message: 'Incorrect API key provided: sk-supersecreta.' } },
307
+ 401,
308
+ ),
309
+ );
310
+
311
+ const outcome = await tester.test(
312
+ { api_key: 'sk-supersecreta' },
313
+ ctx({ secretValues: ['sk-supersecreta'] }),
314
+ );
315
+
316
+ expect(outcome.error).not.toContain('sk-supersecreta');
317
+ expect(outcome.error).toContain('***');
318
+ });
255
319
  });
256
320
 
257
321
  describe('claude tester', () => {
@@ -15,6 +15,16 @@ const EXTERNAL_PROVIDERS = new Set([
15
15
  // Abrir o certificado A1 é código do domínio fiscal: o tester se registra pelo
16
16
  // NfseNationalModule (libraries/fiscal).
17
17
  'nfse-national',
18
+ // App da Meta para redes sociais: o tester se registra pelo SocialModule
19
+ // (libraries/social), junto do client da Graph API.
20
+ 'meta-social',
21
+ // App do Instagram (Instagram Login): o tester vive em libraries/social.
22
+ 'instagram-social',
23
+ // Apps do Google do modulo social: os testers vivem em libraries/social.
24
+ 'youtube-social',
25
+ 'google-business-social',
26
+ // App do LinkedIn (paginas de empresa): o tester vive em libraries/social.
27
+ 'linkedin-social',
18
28
  ]);
19
29
 
20
30
  /** Providers legítimos sem nenhum campo secreto. */