@chat21/chat21-web-widget 5.1.34-rc1 → 5.2.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.
Files changed (55) hide show
  1. package/.github/workflows/docker-community-push-latest.yml +13 -23
  2. package/.github/workflows/docker-image-tag-community-tag-push.yml +12 -22
  3. package/CHANGELOG.md +22 -118
  4. package/Dockerfile +4 -4
  5. package/README.md +1 -1
  6. package/docs/ACCESSIBILITY-STATEMENT.md +388 -0
  7. package/docs/TILEDESK_WIDGET_ACCESSIBILITY_ALIGNMENT.md +60 -0
  8. package/docs/TILEDESK_WIDGET_ACCESSIBILITY_STATEMENT_COMPLETE.md +386 -0
  9. package/docs/changelog/this-branch.md +0 -36
  10. package/nginx.conf +2 -22
  11. package/package.json +1 -1
  12. package/src/app/app.component.ts +9 -10
  13. package/src/app/component/conversation-detail/conversation/conversation.component.html +2 -2
  14. package/src/app/component/conversation-detail/conversation/conversation.component.scss +2 -2
  15. package/src/app/component/conversation-detail/conversation/conversation.component.ts +16 -34
  16. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.html +3 -3
  17. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.scss +2 -2
  18. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.spec.ts +0 -1
  19. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.html +52 -63
  20. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.scss +17 -11
  21. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.spec.ts +10 -4
  22. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.ts +5 -8
  23. package/src/app/component/form/inputs/form-text/form-text.component.ts +1 -1
  24. package/src/app/component/last-message/last-message.component.ts +1 -4
  25. package/src/app/component/message/audio-sync/audio-sync.component.spec.ts +17 -8
  26. package/src/app/component/message/audio-sync/audio-sync.component.ts +96 -25
  27. package/src/app/component/message/bubble-message/bubble-message.component.html +12 -9
  28. package/src/app/component/message/bubble-message/bubble-message.component.spec.ts +38 -45
  29. package/src/app/component/message/bubble-message/bubble-message.component.ts +49 -45
  30. package/src/app/component/message/json-sources/json-sources.component.html +6 -5
  31. package/src/app/component/message/json-sources/json-sources.component.scss +26 -18
  32. package/src/app/component/message/json-sources/json-sources.component.ts +41 -0
  33. package/src/app/providers/global-settings.service.ts +0 -42
  34. package/src/app/providers/json-sources-parser.service.ts +13 -1
  35. package/src/app/providers/translator.service.ts +1 -4
  36. package/src/app/providers/tts-audio-playback-coordinator.service.spec.ts +7 -8
  37. package/src/app/providers/tts-audio-playback-coordinator.service.ts +13 -0
  38. package/src/app/providers/voice/STT&TTS/openai-voice.provider.ts +67 -82
  39. package/src/app/providers/voice/voice.service.spec.ts +35 -35
  40. package/src/app/providers/voice/voice.service.ts +3 -7
  41. package/src/app/sass/_variables.scss +0 -1
  42. package/src/app/utils/globals.ts +2 -8
  43. package/src/assets/i18n/en.json +22 -1
  44. package/src/assets/i18n/es.json +22 -1
  45. package/src/assets/i18n/fr.json +22 -1
  46. package/src/assets/i18n/it.json +22 -1
  47. package/src/assets/twp/index-dev.html +0 -18
  48. package/src/chat21-core/providers/firebase/firebase-init-service.ts +5 -5
  49. package/src/chat21-core/providers/tiledesk/tiledesk-requests.service.ts +1 -1
  50. package/src/chat21-core/utils/utils-message.ts +4 -4
  51. package/src/chat21-core/utils/utils.ts +2 -5
  52. package/src/widget-config-template.json +0 -1
  53. package/src/widget-config.json +28 -30
  54. package/.github/workflows/build.yml +0 -22
  55. package/src/assets/twp/tiledesk_widget_files/widget-css-override-example.css +0 -14
@@ -10,6 +10,12 @@ export type JsonSourceItem = {
10
10
  image?: string;
11
11
  };
12
12
 
13
+ export type JsonSourcesDisplayFields = {
14
+ title?: boolean;
15
+ description?: boolean;
16
+ image?: boolean;
17
+ };
18
+
13
19
  @Component({
14
20
  selector: 'chat-json-sources',
15
21
  templateUrl: './json-sources.component.html',
@@ -19,11 +25,30 @@ export class JsonSourcesComponent {
19
25
  @Input() items: JsonSourceItem[] = [];
20
26
  @Input() themeColor?: string;
21
27
  @Input() limit = 3;
28
+ // Optional: per-field visibility. Missing/undefined fields default to visible
29
+ // (only an explicit `false` hides the field).
30
+ @Input() displayFields?: JsonSourcesDisplayFields;
31
+ // Optional: background color override for the sources panel.
32
+ @Input() backgroundColor?: string;
22
33
 
23
34
  @Output() onElementRendered = new EventEmitter<{ element: string; status: boolean }>();
24
35
 
25
36
  showAll = false;
26
37
 
38
+ isFieldVisible(field: keyof JsonSourcesDisplayFields): boolean {
39
+ return this.displayFields?.[field] !== false;
40
+ }
41
+
42
+ // Title is always rendered: when its content is missing or the field is
43
+ // hidden via displayFields, we fall back to the item URL so the row is never
44
+ // left without a label.
45
+ getTitleText(item: JsonSourceItem): string {
46
+ const titleVisible = this.isFieldVisible('title');
47
+ const title = (item?.title || '').trim();
48
+ if (titleVisible && title) return title;
49
+ return (item?.link || '').trim();
50
+ }
51
+
27
52
  trackByLink = (_: number, item: JsonSourceItem) => item?.link || item?.title || _;
28
53
 
29
54
  ngAfterViewInit() {
@@ -51,6 +76,22 @@ export class JsonSourcesComponent {
51
76
  return hostname || '';
52
77
  }
53
78
 
79
+ // Route large source images through wsrv.nl which downsamples them server-side
80
+ // to a thumbnail-sized version. Rendering at ~3x the CSS size keeps the result
81
+ // sharp on retina displays. Falls back to the original URL on any error.
82
+ getThumbUrl(item: JsonSourceItem): string {
83
+ const raw = (item?.image || '').trim();
84
+ if (!raw) return '';
85
+ if (!/^https?:\/\//i.test(raw)) return raw;
86
+ try {
87
+ const stripped = raw.replace(/^https?:\/\//i, '');
88
+ const encoded = encodeURIComponent(stripped);
89
+ return `https://wsrv.nl/?url=${encoded}&w=120&h=120&fit=cover&output=webp&n=-1`;
90
+ } catch {
91
+ return raw;
92
+ }
93
+ }
94
+
54
95
  private safeHostname(url: string): string {
55
96
  try {
56
97
  return new URL(url).hostname.replace(/^www\./, '');
@@ -66,8 +66,6 @@ export class GlobalSettingsService {
66
66
  this.globals.logLevel = this.appConfigService.getConfig().logLevel
67
67
  /**SET PERSISTENCE parameter */
68
68
  this.globals.persistence = this.appConfigService.getConfig().authPersistence
69
- /**SET CLOSE CHAT IN CONVERSATION parameter */
70
- this.globals.closeChatInConversation = this.appConfigService.getConfig().closeChatInConversation;
71
69
 
72
70
  // ------------------------------- //
73
71
  /** LOAD PARAMETERS FROM SERVER
@@ -340,8 +338,6 @@ export class GlobalSettingsService {
340
338
  this.setCssIframe();
341
339
  /** set main style */
342
340
  this.setStyle();
343
- /** external CSS override: last stylesheet in document head (max cascade priority vs bundle) */
344
- this.applyCustomCssOverrideFromGlobals();
345
341
  this.obsSettingsService.next(true);
346
342
  }
347
343
 
@@ -422,28 +418,6 @@ export class GlobalSettingsService {
422
418
 
423
419
  document.documentElement.style.setProperty('--font-family', family);
424
420
  }
425
-
426
- /**
427
- * Loads `globals.cssSource` (set only from tiledeskSettings) as the last stylesheet in head
428
- * so rules with the same specificity override local / bundled CSS.
429
- */
430
- private applyCustomCssOverrideFromGlobals(): void {
431
- const id = 'tiledesk-widget-css-override';
432
- document.getElementById(id)?.remove();
433
-
434
- const href = (this.globals.cssSource || '').trim();
435
- console.log('href', href);
436
- if (!href) {
437
- return;
438
- }
439
-
440
- const link = document.createElement('link');
441
- link.id = id;
442
- link.rel = 'stylesheet';
443
- link.href = href;
444
- link.setAttribute('data-tiledesk-css-override', 'true');
445
- document.head.appendChild(link);
446
- }
447
421
  /**
448
422
  * A: setVariablesFromService
449
423
  */
@@ -667,11 +641,6 @@ export class GlobalSettingsService {
667
641
  let TEMP: any;
668
642
  const tiledeskSettings = windowContext['tiledeskSettings'];
669
643
  // this.logger.debug('[GLOBAL-SET] setVariablesFromSettings > tiledeskSettings: ', tiledeskSettings);
670
- /** css override URL: solo tiledeskSettings, mai da URL / query params */
671
- TEMP = tiledeskSettings['cssSource'];
672
- if (TEMP !== undefined) {
673
- globals.cssSource = TEMP;
674
- }
675
644
  TEMP = tiledeskSettings['tenant'];
676
645
  // this.logger.debug('[GLOBAL-SET] setVariablesFromSettings > tenant:: ', TEMP);
677
646
  if (TEMP !== undefined) {
@@ -1176,12 +1145,6 @@ export class GlobalSettingsService {
1176
1145
  if (TEMP !== undefined) {
1177
1146
  globals.size = TEMP;
1178
1147
  }
1179
-
1180
- TEMP = tiledeskSettings['closeChatInConversation'];
1181
- // this.logger.debug('[GLOBAL-SET] setVariablesFromSettings > closeChatInConversation:: ', TEMP]);
1182
- if (TEMP !== undefined) {
1183
- globals.closeChatInConversation = (TEMP === true) ? true : false;
1184
- }
1185
1148
  }
1186
1149
 
1187
1150
  /**
@@ -1951,11 +1914,6 @@ export class GlobalSettingsService {
1951
1914
  if (TEMP) {
1952
1915
  globals.size = TEMP;
1953
1916
  }
1954
-
1955
- TEMP = getParameterByName(windowContext, 'tiledesk_closeChatInConversation');
1956
- if (TEMP) {
1957
- globals.closeChatInConversation = stringToBoolean(TEMP);
1958
- }
1959
1917
 
1960
1918
  }
1961
1919
 
@@ -5,9 +5,17 @@ import { extractUrlsFromText } from 'src/app/utils/url-utils';
5
5
  import { JsonSourceItem } from 'src/app/component/message/json-sources/json-sources.component';
6
6
  import { mergeJsonSourcesMissingFields } from 'src/app/utils/json-sources-utils';
7
7
 
8
+ export type UrlPreviewDisplayFields = {
9
+ title?: boolean;
10
+ description?: boolean;
11
+ image?: boolean;
12
+ };
13
+
8
14
  export type UrlPreviewMessage = {
9
15
  type?: string; // "url_preview"
10
16
  text?: string;
17
+ displayFields?: UrlPreviewDisplayFields;
18
+ previewBackgroundColor?: string;
11
19
  };
12
20
 
13
21
  /**
@@ -79,7 +87,11 @@ export class JsonSourcesParserService {
79
87
  return this.enrichSources(base);
80
88
  }
81
89
 
82
- private getUrlPreviewPayload(messageLike?: any): UrlPreviewMessage | null {
90
+ /**
91
+ * Public: lets callers (UI components) read the raw `url_preview` payload to
92
+ * extract presentation options like `displayFields` or `previewBackgroundColor`.
93
+ */
94
+ getUrlPreviewPayload(messageLike?: any): UrlPreviewMessage | null {
83
95
  if (!messageLike) return null;
84
96
  const candidates: any[] = [
85
97
  messageLike,
@@ -318,9 +318,7 @@ export class TranslatorService {
318
318
  'LABEL_PREVIEW',
319
319
  'MAX_ATTACHMENT',
320
320
  'EMOJI',
321
- 'BUTTON_OPEN_CHAT',
322
- 'MAX_ATTACHMENT_ERROR',
323
- 'EMOJI'
321
+ 'BUTTON_OPEN_CHAT'
324
322
  ];
325
323
 
326
324
 
@@ -376,7 +374,6 @@ export class TranslatorService {
376
374
  globals.LABEL_PREVIEW = res['LABEL_PREVIEW']
377
375
  globals.LABEL_ERROR_FIELD_REQUIRED= res['LABEL_ERROR_FIELD_REQUIRED']
378
376
  globals.MAX_ATTACHMENT = res['MAX_ATTACHMENT']
379
- globals.MAX_ATTACHMENT_ERROR = res['MAX_ATTACHMENT_ERROR']
380
377
  globals.EMOJI = res['EMOJI']
381
378
  globals.BUTTON_OPEN_CHAT = res['BUTTON_OPEN_CHAT']
382
379
 
@@ -1,12 +1,10 @@
1
- import { TestBed } from '@angular/core/testing';
2
1
  import { TtsAudioPlaybackCoordinator } from './tts-audio-playback-coordinator.service';
3
2
 
4
3
  describe('TtsAudioPlaybackCoordinator', () => {
5
4
  let coordinator: TtsAudioPlaybackCoordinator;
6
5
 
7
6
  beforeEach(() => {
8
- TestBed.configureTestingModule({ providers: [TtsAudioPlaybackCoordinator] });
9
- coordinator = TestBed.inject(TtsAudioPlaybackCoordinator);
7
+ coordinator = new TtsAudioPlaybackCoordinator();
10
8
  });
11
9
 
12
10
  // ── Basic lifecycle ───────────────────────────────────────────────────────
@@ -28,15 +26,16 @@ describe('TtsAudioPlaybackCoordinator', () => {
28
26
  });
29
27
 
30
28
  it('stopAll clears the queue, sets playing=false, and emits stopAllPlayback$', () => {
31
- const stopNextSpy = spyOn((coordinator as any)._stopAll$, 'next').and.callThrough();
29
+ const stopAllFired: void[] = [];
30
+ coordinator.stopAllPlayback$.subscribe(() => stopAllFired.push(undefined));
32
31
 
33
32
  coordinator.requestStart('msg-1', () => {});
34
33
  coordinator.stopAll();
35
34
 
36
- expect(stopNextSpy).toHaveBeenCalledTimes(1);
37
- const states: boolean[] = [];
38
- coordinator.isTTSPlaying$.subscribe((v) => states.push(v));
39
- expect(states).toEqual([false]);
35
+ let playing = true;
36
+ coordinator.isTTSPlaying$.subscribe((v) => (playing = v));
37
+ expect(playing).toBe(false);
38
+ expect(stopAllFired.length).toBe(1);
40
39
  });
41
40
 
42
41
  // ── Preemption tests (SPEC-002) ───────────────────────────────────────────
@@ -11,6 +11,10 @@ export class TtsAudioPlaybackCoordinator {
11
11
  private currentOwnerId: string | null = null;
12
12
  private readonly queue: Array<{ ownerId: string; start: () => void }> = [];
13
13
 
14
+ private readonly cancelAllSource = new Subject<void>();
15
+ /** Emesso quando la riproduzione TTS va interrotta globalmente (es. l’utente parla al microfono). */
16
+ readonly cancelAll$: Observable<void> = this.cancelAllSource.asObservable();
17
+
14
18
  /** Emits true while any TTS is playing or queued; false when the queue is fully drained. */
15
19
  private readonly _isTTSPlaying$ = new BehaviorSubject<boolean>(false);
16
20
  readonly isTTSPlaying$ = this._isTTSPlaying$.asObservable();
@@ -96,6 +100,15 @@ export class TtsAudioPlaybackCoordinator {
96
100
  this.releaseIfCurrent(ownerId);
97
101
  }
98
102
 
103
+ /**
104
+ * Interrompe TUTTA la riproduzione TTS (corrente + coda) e notifica i componenti.
105
+ * I componenti devono fermare l’audio e mostrare il testo per intero.
106
+ */
107
+ cancelAll(): void {
108
+ this.stopAll();
109
+ this.cancelAllSource.next();
110
+ }
111
+
99
112
  /**
100
113
  * Stops all TTS playback immediately and clears the queue.
101
114
  * Broadcasts on stopAllPlayback$ so every AudioSyncComponent can abort its stream and reveal all text.
@@ -1,9 +1,7 @@
1
1
  import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http';
2
2
  import { Injectable } from '@angular/core';
3
3
  import { firstValueFrom } from 'rxjs';
4
- import { environment } from 'src/environments/environment';
5
4
 
6
- import type { OpenAiVoiceEnvironmentConfig } from './openai-voice.config';
7
5
  import {
8
6
  SpeechToTextProvider,
9
7
  TextToSpeechProvider,
@@ -14,121 +12,121 @@ import {
14
12
  } from './speech-provider.abstract';
15
13
  import { AppConfigService } from '../../app-config.service';
16
14
 
17
- const DEFAULT_BASE = 'https://api.openai.com/v1';
18
- const DEFAULT_TRANSCRIPTION_MODEL = 'whisper-1';
19
- const DEFAULT_TTS_MODEL = 'tts-1';
20
- const DEFAULT_VOICE = 'alloy';
21
- const DEFAULT_FORMAT = 'mp3';
22
-
23
15
  /**
24
- * Provider OpenAI unico: STT (Whisper) + TTS, entrambi via {@link HttpClient}.
16
+ * Routes STT and TTS calls through the tiledesk-speech-proxy.
17
+ *
18
+ * STT: POST <proxyBase>/api/stt — multipart/form-data, field "audio"
19
+ * TTS: POST <proxyBase>/api/tts — JSON body { text, ... }
25
20
  */
26
21
  @Injectable({ providedIn: 'root' })
27
22
  export class OpenAiVoiceProviderService extends SpeechToTextProvider implements TextToSpeechProvider {
28
23
  constructor(
29
24
  private readonly httpClient: HttpClient,
30
- private readonly appConfig: AppConfigService
25
+ private readonly appConfig: AppConfigService,
31
26
  ) {
32
27
  super();
33
28
  }
34
29
 
35
30
  async transcribe(request: SpeechToTextRequest): Promise<SpeechToTextResult> {
36
- const cfg = this.getConfig();
37
- const apiKey = cfg.apiKey?.trim();
38
- if (!apiKey) {
31
+ const proxyBase = this.proxyBase();
32
+ if (!proxyBase) {
39
33
  return { text: '' };
40
34
  }
41
35
 
42
- const base = (cfg.baseUrl ?? DEFAULT_BASE).replace(/\/$/, '');
43
- const model = cfg.transcriptionModel ?? DEFAULT_TRANSCRIPTION_MODEL;
44
- const url = `${base}/audio/transcriptions`;
45
-
36
+ const url = `${proxyBase}/api/stt`;
46
37
  const ext = this.extensionForMime(request.mimeType);
47
38
  const file = new File([request.audio], `segment.${ext}`, { type: request.mimeType });
48
39
 
49
40
  const form = new FormData();
50
- form.append('file', file);
51
- form.append('model', model);
41
+ form.append('audio', file);
52
42
  if (request.language) {
53
43
  form.append('language', request.language);
54
44
  }
45
+ const projectId = String(this.appConfig.g?.projectid ?? '').trim();
46
+ if (projectId) form.append('projectId', projectId);
47
+ const requestId = this.parseRequestId(this.appConfig.g?.recipientId ?? '');
48
+ if (requestId) {
49
+ form.append('requestId', requestId);
50
+ }
55
51
 
56
- const headers = new HttpHeaders({
57
- Authorization: `Bearer ${apiKey}`,
58
- });
52
+ const headers = new HttpHeaders({ Authorization: this.authHeader() });
59
53
 
60
54
  try {
61
55
  const data = await firstValueFrom(
62
- this.httpClient.post<{ text?: string }>(url, form, { headers }),
56
+ this.httpClient.post<{ transcript?: string }>(url, form, { headers }),
63
57
  );
64
- return { text: (data.text ?? '').trim() };
58
+ return { text: (data.transcript ?? '').trim() };
65
59
  } catch (e) {
66
60
  if (e instanceof HttpErrorResponse && e.error instanceof Blob) {
67
61
  const errText = await e.error.text();
68
- throw new Error(`OpenAI transcription ${e.status}: ${errText || e.statusText}`);
62
+ throw new Error(`Speech proxy STT ${e.status}: ${errText || e.statusText}`);
69
63
  }
70
- throw this.mapOpenAiHttpError(e);
64
+ throw this.mapHttpError('Speech proxy STT', e);
71
65
  }
72
66
  }
73
67
 
74
68
  async synthesize(request: TextToSpeechRequest): Promise<TextToSpeechResult> {
75
- const cfg = this.getConfig();
76
- const apiKey = cfg.apiKey?.trim();
77
- if (!apiKey) {
78
- throw new Error('OpenAI API key not configured (environment.openAiVoice.apiKey)');
69
+ const proxyBase = this.proxyBase();
70
+ if (!proxyBase) {
71
+ throw new Error('voiceProxyApiBaseUrl not configured');
79
72
  }
80
73
 
81
- const base = (cfg.baseUrl ?? DEFAULT_BASE).replace(/\/$/, '');
82
- const model = cfg.ttsModel ?? DEFAULT_TTS_MODEL;
83
- const voice = request.voice ?? cfg.ttsVoice ?? DEFAULT_VOICE;
84
- const responseFormat =
85
- (request.responseFormat as 'mp3' | 'opus' | 'aac' | 'flac' | undefined) ?? DEFAULT_FORMAT;
86
- const url = `${base}/audio/speech`;
87
-
88
- const body = {
89
- model,
90
- voice,
91
- input: request.text,
92
- response_format: responseFormat,
93
- };
74
+ const url = `${proxyBase}/api/tts`;
75
+ const body: Record<string, unknown> = { text: request.text };
76
+ if (request.language) body['language'] = request.language;
77
+ if (request.voice) body['voiceId'] = request.voice;
78
+ if (request.responseFormat) body['outputFormat'] = request.responseFormat;
79
+ const projectId = String(this.appConfig.g?.projectid ?? '').trim();
80
+ if (projectId) body['projectId'] = projectId;
81
+ const requestId = this.parseRequestId(this.appConfig.g?.recipientId ?? '');
82
+ if (requestId) body['requestId'] = requestId;
94
83
 
95
84
  const headers = new HttpHeaders({
96
- Authorization: `Bearer ${apiKey}`,
85
+ Authorization: this.authHeader(),
97
86
  'Content-Type': 'application/json',
98
87
  });
99
88
 
100
89
  try {
101
90
  const blob = await firstValueFrom(
102
- this.httpClient.post(url, body, {
103
- headers,
104
- responseType: 'blob',
105
- }),
91
+ this.httpClient.post(url, body, { headers, responseType: 'blob' }),
106
92
  );
107
- return { audio: blob, mimeType: this.mimeForFormat(responseFormat) };
93
+ return { audio: blob, mimeType: this.mimeForFormat(request.responseFormat ?? 'mp3') };
108
94
  } catch (e) {
109
95
  if (e instanceof HttpErrorResponse && e.error instanceof Blob) {
110
96
  const errText = await e.error.text();
111
- throw new Error(`OpenAI TTS ${e.status}: ${errText || e.statusText}`);
97
+ throw new Error(`Speech proxy TTS ${e.status}: ${errText || e.statusText}`);
112
98
  }
113
- if (e instanceof HttpErrorResponse) {
114
- throw new Error(`OpenAI TTS ${e.status}: ${e.message || e.statusText}`);
115
- }
116
- throw e;
99
+ throw this.mapHttpError('Speech proxy TTS', e);
117
100
  }
118
101
  }
119
102
 
120
- private getConfig(): OpenAiVoiceEnvironmentConfig {
121
- return this.appConfig.getConfig().openAiKey ?? {};
103
+ private proxyBase(): string | null {
104
+ const base = String(this.appConfig.getConfig()?.voiceProxyApiBaseUrl ?? '').trim();
105
+ return base ? base.replace(/\/$/, '') : null;
106
+ }
107
+
108
+ /** Returns `JWT <rawToken>`, stripping any existing prefix first. */
109
+ private authHeader(): string {
110
+ const raw = (
111
+ String(this.appConfig.g?.tiledeskToken ?? this.appConfig.g?.jwt ?? '').trim()
112
+ ).replace(/^(JWT|Bearer)\s+/i, '').trim();
113
+ return raw ? `JWT ${raw}` : '';
114
+ }
115
+
116
+ /**
117
+ * Extracts the Tiledesk requestId from a Chat21 recipient string.
118
+ * Format: `support-group-<projectId>-<requestId>`
119
+ */
120
+ private parseRequestId(recipient: string): string | null {
121
+ const parts = recipient.split('-');
122
+ if (parts.length < 4) return null;
123
+ return parts.slice(3).join('-') || null;
122
124
  }
123
125
 
124
- private mapOpenAiHttpError(e: unknown): Error {
126
+ private mapHttpError(label: string, e: unknown): Error {
125
127
  if (!(e instanceof HttpErrorResponse)) {
126
128
  return e instanceof Error ? e : new Error(String(e));
127
129
  }
128
- const label = 'OpenAI transcription';
129
- if (e.error instanceof Blob) {
130
- return new Error(`${label} ${e.status}: ${e.statusText}`);
131
- }
132
130
  if (typeof e.error === 'object' && e.error !== null && 'error' in e.error) {
133
131
  const err = (e.error as { error?: { message?: string } }).error;
134
132
  return new Error(`${label} ${e.status}: ${err?.message ?? JSON.stringify(e.error)}`);
@@ -140,32 +138,19 @@ export class OpenAiVoiceProviderService extends SpeechToTextProvider implements
140
138
  }
141
139
 
142
140
  private extensionForMime(mime: string): string {
143
- if (mime.includes('webm')) {
144
- return 'webm';
145
- }
146
- if (mime.includes('mp4') || mime.includes('m4a')) {
147
- return 'm4a';
148
- }
149
- if (mime.includes('wav')) {
150
- return 'wav';
151
- }
152
- if (mime.includes('mpeg') || mime.includes('mp3')) {
153
- return 'mp3';
154
- }
141
+ if (mime.includes('webm')) return 'webm';
142
+ if (mime.includes('mp4') || mime.includes('m4a')) return 'm4a';
143
+ if (mime.includes('wav')) return 'wav';
144
+ if (mime.includes('mpeg') || mime.includes('mp3')) return 'mp3';
155
145
  return 'webm';
156
146
  }
157
147
 
158
148
  private mimeForFormat(fmt: string): string {
159
149
  switch (fmt) {
160
- case 'opus':
161
- return 'audio/opus';
162
- case 'aac':
163
- return 'audio/aac';
164
- case 'flac':
165
- return 'audio/flac';
166
- case 'mp3':
167
- default:
168
- return 'audio/mpeg';
150
+ case 'opus': return 'audio/opus';
151
+ case 'aac': return 'audio/aac';
152
+ case 'flac': return 'audio/flac';
153
+ default: return 'audio/mpeg';
169
154
  }
170
155
  }
171
156
  }