Merge remote-tracking branch 'origin/feat/voice-call-model' into feat/whatsapp-call

# Conflicts:
#	config/features.yml
#	db/schema.rb
#	enterprise/app/models/enterprise/concerns/account.rb
This commit is contained in:
root
2026-04-12 17:03:14 +00:00
532 changed files with 23290 additions and 16591 deletions
@@ -32,6 +32,25 @@ export function extractTextFromMarkdown(markdown) {
.trim(); // Trim any extra space
}
/**
* Removes inline base64 markdown images from signature content.
*
* @param {string} content
* @returns {{ sanitizedContent: string, hasInlineImages: boolean }}
*/
export function stripInlineBase64Images(content) {
if (!content || typeof content !== 'string') {
return { sanitizedContent: content || '', hasInlineImages: false };
}
const markdownInlineBase64ImageRegex =
/!\[[^\]]*]\(\s*data:image\/[a-zA-Z0-9.+-]+;base64,[^)]+\s*\)/gi;
const sanitizedContent = content.replace(markdownInlineBase64ImageRegex, '');
const hasInlineImages = sanitizedContent !== content;
return { sanitizedContent, hasInlineImages };
}
/**
* Strip unsupported markdown formatting based on channel capabilities.
* Uses MARKDOWN_PATTERNS from editor constants.
@@ -166,6 +166,8 @@ const TOD_TO_MERIDIEM = {
evening: 'pm',
night: 'pm',
};
const CJK_CHAR_RE =
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
// ─── Translation Cache ──────────────────────────────────────────────────────
@@ -278,8 +280,13 @@ const escapeRegex = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const substituteLocalTokens = (text, pairs) => {
let r = text;
pairs.forEach(([local, en]) => {
const re = new RegExp(`(?<=^|\\s)${escapeRegex(local)}(?=\\s|$)`, 'g');
r = r.replace(re, en);
if (CJK_CHAR_RE.test(local)) {
const re = new RegExp(escapeRegex(local), 'g');
r = r.replace(re, ` ${en} `);
} else {
const re = new RegExp(`(?<=^|\\s)${escapeRegex(local)}(?=\\s|$)`, 'g');
r = r.replace(re, en);
}
});
return r;
};
@@ -82,6 +82,9 @@ const ORDINAL_RE = `(\\d{1,2}(?:st|nd|rd|th)?|${ORDINAL_WORDS})`;
const HALF_UNIT_RE = /^(?:in\s+)?half\s+(?:an?\s+)?(hour|day|week|month|year)$/;
const RELATIVE_DURATION_RE = new RegExp(`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}$`);
const RELATIVE_DURATION_AFTER_RE = new RegExp(
`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}\\s+after$`
);
const DURATION_FROM_NOW_RE = new RegExp(
`^${NUM_RE}\\s+${UNIT_RE}\\s+from\\s+now$`
);
@@ -89,6 +92,9 @@ const RELATIVE_DAY_ONLY_RE = new RegExp(`^(${RELATIVE_DAYS})$`);
const RELATIVE_DAY_TOD_RE = new RegExp(
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})$`
);
const RELATIVE_DAY_MERIDIEM_RE = new RegExp(
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(am|pm)$`
);
const RELATIVE_DAY_TOD_TIME_RE = new RegExp(
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$`
);
@@ -245,6 +251,7 @@ const matchDuration = (text, now) => {
return (
parseDuration(text.match(DURATION_FROM_NOW_RE), now) ||
parseDuration(text.match(RELATIVE_DURATION_AFTER_RE), now) ||
parseDuration(text.match(RELATIVE_DURATION_RE), now)
);
};
@@ -303,6 +310,13 @@ const matchRelativeDay = (text, now) => {
);
}
const dayMeridiemMatch = text.match(RELATIVE_DAY_MERIDIEM_RE);
if (dayMeridiemMatch) {
const [, dayKey, meridiem] = dayMeridiemMatch;
const hours = meridiem === 'am' ? 9 : 14;
return applyTimeWithRollover(RELATIVE_DAY_MAP[dayKey], hours, 0, now);
}
const dayAtTimeMatch = text.match(RELATIVE_DAY_AT_TIME_RE);
if (dayAtTimeMatch) {
const [, dayKey, timeRaw] = dayAtTimeMatch;
@@ -15,6 +15,7 @@ import {
getMenuAnchor,
calculateMenuPosition,
stripUnsupportedFormatting,
stripInlineBase64Images,
} from '../editorHelper';
import { FORMATTING } from 'dashboard/constants/editor';
import { EditorState } from '@chatwoot/prosemirror-schema';
@@ -423,6 +424,36 @@ describe('extractTextFromMarkdown', () => {
});
});
describe('stripInlineBase64Images', () => {
it('removes markdown data:image base64 images and sets hasInlineImages', () => {
const content =
'Hello\n![x](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE)\nWorld';
const { sanitizedContent, hasInlineImages } =
stripInlineBase64Images(content);
expect(hasInlineImages).toBe(true);
expect(sanitizedContent).not.toContain('data:image/png;base64');
expect(sanitizedContent).toContain('Hello');
expect(sanitizedContent).toContain('World');
});
it('leaves hosted image markdown unchanged', () => {
const content = '![](https://example.com/logo.png)';
const { sanitizedContent, hasInlineImages } =
stripInlineBase64Images(content);
expect(hasInlineImages).toBe(false);
expect(sanitizedContent).toBe(content);
});
it('returns empty hasInlineImages for empty input', () => {
expect(stripInlineBase64Images('')).toEqual({
sanitizedContent: '',
hasInlineImages: false,
});
});
});
describe('insertAtCursor', () => {
it('should return undefined if editorView is not provided', () => {
const result = insertAtCursor(undefined, schema.text('Hello'), 0);
@@ -1626,6 +1626,24 @@ describe('generateDateSuggestions — localized input regressions', () => {
},
};
const zhTWSnoozeTranslations = {
UNITS: {
HOUR: '小時',
HOURS: '小時',
DAY: '天',
DAYS: '天',
},
HALF: '半',
RELATIVE: {
TOMORROW: '明天',
},
MERIDIEM: {
AM: '上午',
PM: '下午',
},
AFTER: '後',
};
describe('P1: short non-English tokens must NOT produce spurious half-duration suggestions', () => {
it('Arabic "غد" does not produce half-duration suggestions', () => {
const results = generateDateSuggestions('غد', now, {
@@ -1721,6 +1739,37 @@ describe('generateDateSuggestions — localized input regressions', () => {
expect(results[0].date.getHours()).toBe(6);
});
});
describe('zh_TW compact CJK inputs', () => {
const options = {
translations: zhTWSnoozeTranslations,
locale: 'zh-TW',
};
it('parses "2小時後" (2 hours from now) without spaces', () => {
const results = generateDateSuggestions('2小時後', now, options);
expect(results.length).toBeGreaterThan(0);
expect(results[0].date.getDate()).toBe(16);
expect(results[0].date.getHours()).toBe(12);
expect(results[0].date.getMinutes()).toBe(0);
});
it('parses "半天" (half day) without spaces', () => {
const results = generateDateSuggestions('半天', now, options);
expect(results.length).toBeGreaterThan(0);
expect(results[0].date.getDate()).toBe(16);
expect(results[0].date.getHours()).toBe(22);
expect(results[0].date.getMinutes()).toBe(0);
});
it('parses "明天 上午" (tomorrow AM) into tomorrow 9am', () => {
const results = generateDateSuggestions('明天 上午', now, options);
expect(results.length).toBeGreaterThan(0);
expect(results[0].date.getDate()).toBe(17);
expect(results[0].date.getHours()).toBe(9);
expect(results[0].date.getMinutes()).toBe(0);
});
});
});
describe('no-space duration suggestions', () => {