Compare commits

..

2 Commits

Author SHA1 Message Date
leo d314d45c6e wip 2026-06-10 14:44:32 +02:00
leo f85159c9b6 wip 2026-06-10 14:34:52 +02:00
83 changed files with 2642 additions and 1341 deletions
+2 -31
View File
@@ -8,38 +8,9 @@ and this project adheres to
## [Unreleased]
## [1.21.0] - 2026-06-15
### Added
- ✨(frontend) allow disabling silent login via a URL parameter
- ✨(frontend) allow hiding the login button via a URL parameter
- ✨(summary) add optional satisfaction survey footer
### Changed
- ✨(frontend) enhance noise reduction with BBBA audio processing pipeline
- 🚸(frontend) mute join notification sound in larger rooms
- 🚸(frontend) mute participants by default when joining a large meeting
### Fixed
- 🐛(frontend) fix metadata agent collector enabled check
### Fixed
- ♿️(frontend) improve accessibilty of the Effects panel #1401
## [1.20.0] - 2026-06-12
### Changed
- ♻️(addon) improve Outlook add-on: i18n support, feedback link, smarter link
- ⬆️(frontend) upgrade react-i18next from 15.1.1 to 17.0.8
### Fixed
- 🐛(frontend) fix noise reduction left-channel-only audio
- ✨(backend) add structured audit logging
## [1.19.0] - 2026-06-04
@@ -58,7 +29,7 @@ and this project adheres to
- 🔇(summary) make ffmpeg quiet #1404
- 🔒️(backend) prevent accessing files if they are not ready #1395
- # ⬆️(backend) upgrade idna to >=3.15 to address CVE-2026-45409
- ⬆️(backend) upgrade idna to >=3.15 to address CVE-2026-45409
## [1.18.0] - 2026-06-03
-2
View File
@@ -60,8 +60,6 @@ USER root
# Security patches for known CVEs
RUN apk update && apk upgrade \
libcrypto3>=3.5.7-r0 \
libssl3>=3.5.7-r0 \
musl \
musl-utils \
zlib>=1.3.2-r0 \
+1 -4
View File
@@ -48,12 +48,9 @@ server {
set $nonce $request_id;
set $csp "default-src 'self'; upgrade-insecure-requests; ";
set $csp "upgrade-insecure-requests; ";
set $csp "${csp}frame-ancestors ${ms_domains}; ";
set $csp "${csp}script-src 'nonce-${nonce}' 'strict-dynamic'; ";
set $csp "${csp}style-src 'self' 'unsafe-inline'; ";
set $csp "${csp}img-src 'self' data:; ";
set $csp "${csp}font-src 'self' data:; ";
set $csp "${csp}connect-src 'self' ${ms_domains}; ";
set $csp "${csp}frame-src 'none'; ";
set $csp "${csp}object-src 'none'; ";
-3
View File
@@ -22,6 +22,3 @@ WEBHOOK_URL="https://configure-your-url.com"
POSTHOG_API_KEY="your-posthog-key"
POSTHOG_ENABLED="False"
# Transcription
TRANSCRIPTION_SATISFACTION_FORM_BASE_URL=
+13 -32
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0" xsi:type="MailApp">
<Id>a025f0f6-757a-4790-97f3-99c66c4a5795</Id>
<Version>0.0.2.0</Version>
<Version>0.0.1.0</Version>
<ProviderName>__APP_NAME__</ProviderName>
<DefaultLocale>fr-FR</DefaultLocale>
<DisplayName DefaultValue="__APP_NAME__"/>
@@ -87,9 +87,9 @@
<Description resid="GenerateLink.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
<bt:Image size="16" resid="Add.16x16"/>
<bt:Image size="32" resid="Add.32x32"/>
<bt:Image size="80" resid="Add.80x80"/>
</Icon>
<Action xsi:type="ExecuteFunction">
<FunctionName>generateMeetingLinkFromMail</FunctionName>
@@ -126,9 +126,9 @@
<Description resid="GenerateLink.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
<bt:Image size="16" resid="Add.16x16"/>
<bt:Image size="32" resid="Add.32x32"/>
<bt:Image size="80" resid="Add.80x80"/>
</Icon>
<Action xsi:type="ExecuteFunction">
<FunctionName>generateMeetingLinkFromCalendar</FunctionName>
@@ -175,34 +175,15 @@
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/taskpane.html"/>
</bt:Urls>
<bt:ShortStrings>
<!-- Default (French) -->
<bt:String id="GroupLabel" DefaultValue="__APP_NAME__"/>
<bt:String id="GenerateLink.Label" DefaultValue="Ajouter un lien __APP_NAME__">
<bt:Override Locale="en-US" Value="Add a __APP_NAME__ link"/>
<bt:Override Locale="de-DE" Value="__APP_NAME__-Link hinzufügen"/>
</bt:String>
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir les paramètres">
<bt:Override Locale="en-US" Value="Open settings"/>
<bt:Override Locale="de-DE" Value="Einstellungen öffnen"/>
</bt:String>
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres">
<bt:Override Locale="en-US" Value="Settings"/>
<bt:Override Locale="de-DE" Value="Einstellungen"/>
</bt:String>
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir les paramètres"/>
<bt:String id="GenerateLink.Label" DefaultValue="Ajouter un lien __APP_NAME__"/>
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres"/>
</bt:ShortStrings>
<bt:LongStrings>
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion __APP_NAME__ et l'insère dans l'événement.">
<bt:Override Locale="de-DE" Value="Generiert einen __APP_NAME__-Besprechungslink und fügt ihn in den Termin ein."/>
<bt:Override Locale="en-US" Value="Generates a __APP_NAME__ meeting link and inserts it into the item."/>
</bt:String>
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__.">
<bt:Override Locale="de-DE" Value="Öffnet die __APP_NAME__-Verbindungseinstellungen."/>
<bt:Override Locale="en-US" Value="Opens the __APP_NAME__ connection settings."/>
</bt:String>
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__.">
<bt:Override Locale="de-DE" Value="Öffnet die __APP_NAME__-Verbindungseinstellungen."/>
<bt:Override Locale="en-US" Value="Opens the __APP_NAME__ connection settings."/>
</bt:String>
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__."/>
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion __APP_NAME__ et l'insère dans l'événement."/>
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__."/>
</bt:LongStrings>
</Resources>
</VersionOverrides>
+28 -65
View File
@@ -9,35 +9,33 @@
"version": "0.0.1",
"license": "MIT",
"dependencies": {
"core-js": "3.49.0",
"i18next": "26.3.1",
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
"core-js": "^3.49.0",
"regenerator-runtime": "^0.14.1"
},
"devDependencies": {
"@babel/core": "7.29.0",
"@babel/preset-env": "7.29.0",
"@types/office-js": "1.0.582",
"@types/office-runtime": "1.0.36",
"acorn": "8.16.0",
"babel-loader": "9.2.1",
"copy-webpack-plugin": "14.0.0",
"eslint-plugin-office-addins": "4.0.6",
"file-loader": "6.2.0",
"html-loader": "5.1.0",
"html-webpack-inject-attributes-plugin": "1.0.6",
"html-webpack-plugin": "5.6.6",
"office-addin-cli": "2.0.6",
"office-addin-debugging": "6.0.6",
"office-addin-dev-certs": "2.0.6",
"office-addin-lint": "3.0.6",
"office-addin-manifest": "2.1.2",
"office-addin-prettier-config": "2.0.1",
"os-browserify": "0.3.0",
"process": "0.11.10",
"source-map-loader": "5.0.0",
"webpack": "5.105.4",
"webpack-cli": "5.1.4",
"@babel/core": "^7.24.0",
"@babel/preset-env": "^7.25.4",
"@types/office-js": "^1.0.377",
"@types/office-runtime": "^1.0.35",
"acorn": "^8.11.3",
"babel-loader": "^9.1.3",
"copy-webpack-plugin": "^14.0.0",
"eslint-plugin-office-addins": "^4.0.3",
"file-loader": "^6.2.0",
"html-loader": "^5.0.0",
"html-webpack-inject-attributes-plugin": "^1.0.6",
"html-webpack-plugin": "^5.6.0",
"office-addin-cli": "^2.0.3",
"office-addin-debugging": "^6.0.3",
"office-addin-dev-certs": "^2.0.3",
"office-addin-lint": "^3.0.3",
"office-addin-manifest": "^2.0.3",
"office-addin-prettier-config": "^2.0.1",
"os-browserify": "^0.3.0",
"process": "^0.11.10",
"source-map-loader": "^5.0.0",
"webpack": "^5.95.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "5.2.4"
}
},
@@ -2113,7 +2111,9 @@
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
@@ -9363,43 +9363,6 @@
"node": ">=10.18"
}
},
"node_modules/i18next": {
"version": "26.3.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -15250,7 +15213,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
+25 -27
View File
@@ -26,35 +26,33 @@
"watch": "webpack --mode development --watch"
},
"dependencies": {
"core-js": "3.49.0",
"i18next": "26.3.1",
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
"core-js": "^3.49.0",
"regenerator-runtime": "^0.14.1"
},
"devDependencies": {
"@babel/core": "7.29.0",
"@babel/preset-env": "7.29.0",
"@types/office-js": "1.0.582",
"@types/office-runtime": "1.0.36",
"acorn": "8.16.0",
"babel-loader": "9.2.1",
"copy-webpack-plugin": "14.0.0",
"eslint-plugin-office-addins": "4.0.6",
"file-loader": "6.2.0",
"html-loader": "5.1.0",
"html-webpack-inject-attributes-plugin": "1.0.6",
"html-webpack-plugin": "5.6.6",
"office-addin-cli": "2.0.6",
"office-addin-debugging": "6.0.6",
"office-addin-dev-certs": "2.0.6",
"office-addin-lint": "3.0.6",
"office-addin-manifest": "2.1.2",
"office-addin-prettier-config": "2.0.1",
"os-browserify": "0.3.0",
"process": "0.11.10",
"source-map-loader": "5.0.0",
"webpack": "5.105.4",
"webpack-cli": "5.1.4",
"@babel/core": "^7.24.0",
"@babel/preset-env": "^7.25.4",
"@types/office-js": "^1.0.377",
"@types/office-runtime": "^1.0.35",
"acorn": "^8.11.3",
"babel-loader": "^9.1.3",
"copy-webpack-plugin": "^14.0.0",
"eslint-plugin-office-addins": "^4.0.3",
"file-loader": "^6.2.0",
"html-loader": "^5.0.0",
"html-webpack-inject-attributes-plugin": "^1.0.6",
"html-webpack-plugin": "^5.6.0",
"office-addin-cli": "^2.0.3",
"office-addin-debugging": "^6.0.3",
"office-addin-dev-certs": "^2.0.3",
"office-addin-lint": "^3.0.3",
"office-addin-manifest": "^2.0.3",
"office-addin-prettier-config": "^2.0.1",
"os-browserify": "^0.3.0",
"process": "^0.11.10",
"source-map-loader": "^5.0.0",
"webpack": "^5.95.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "5.2.4"
},
"prettier": "office-addin-prettier-config",
+29 -46
View File
@@ -1,18 +1,12 @@
/* global Office */
const { APP_NAME } = require("../common/index");
const { createRoom, initSession } = require("../common/api");
const { startPolling } = require("../common/polling");
const { saveSession, loadSession } = require("../common/session");
const { openTransitDialog } = require("../common/transitDialog");
const { buildMeetingMessage } = require("../common/messageBuilder");
const { applyAppName } = require("../common/helpers");
const { initI18n, t } = require("../common/i18n");
const { isMeetingAlreadyAdded } = require("../common/meetingDetector");
Office.onReady(async function (info) {
await initI18n()
Office.onReady(function (info) {
if (info.host === Office.HostType.Outlook) {
applyAppName();
}
@@ -28,52 +22,41 @@ function notify(message) {
}
function insertMeetingLink(event, session) {
const item = Office.context.mailbox.item;
isMeetingAlreadyAdded(item)
.then((alreadyAdded) => {
if (alreadyAdded) {
notify(t("meeting.already_added", { app_name: APP_NAME }));
event.completed();
return;
}
return _doInsertMeetingLink(event, session);
})
.catch((err) => {
notify(t("meeting.error.details", { message: err.message }));
event.completed();
});
}
function _doInsertMeetingLink(event, session) {
createRoom(session)
.then((data) => {
const isWeb = Office.context.diagnostics.platform === "OfficeOnline";
const { url, text } = buildMeetingMessage(data, isWeb);
const { url, message } = buildMeetingMessage(data);
const item = Office.context.mailbox.item;
const coercionType = isWeb ? Office.CoercionType.Html : Office.CoercionType.Text;
return new Promise((resolve, reject) => {
item.body.setSelectedDataAsync(text, { coercionType }, (setResult) => {
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
notify(t("meeting.error.details", { message: setResult.error.message }));
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
notify(`Erreur de lecture : ${getResult.error.message}`);
resolve();
return;
}
if (item.itemType !== Office.MailboxEnums.ItemType.Appointment) {
notify(t("meeting.link_inserted"));
resolve();
return;
}
item.location.setAsync(url, (locationResult) => {
if (locationResult.status !== Office.AsyncResultStatus.Succeeded) {
notify(t("meeting.error.details", { message: locationResult.error.message }));
} else {
notify(t("meeting.link_inserted"));
const newBody = getResult.value + message;
item.body.setAsync(newBody, { coercionType: Office.CoercionType.Html }, (setResult) => {
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
notify(`Erreur d'insertion : ${setResult.error.message}`);
resolve();
return;
}
resolve();
if (item.itemType !== Office.MailboxEnums.ItemType.Appointment) {
notify("Lien de réunion inséré !");
resolve();
return;
}
item.location.setAsync(url, (locationResult) => {
if (locationResult.status !== Office.AsyncResultStatus.Succeeded) {
notify(`Erreur de localisation : ${locationResult.error.message}`);
} else {
notify("Lien de réunion inséré !");
}
resolve();
});
});
});
});
@@ -96,11 +79,11 @@ function connect(event) {
});
},
onTimeout: () => {
notify(t("meeting.error.auth"));
notify("Connexion expirée, veuillez réessayer.");
event.completed();
},
onError: (err) => {
notify(t("meeting.error.retry"));
notify("Une erreur est survenue, veuillez ré-essayer");
event.completed();
},
});
@@ -116,7 +99,7 @@ function connect(event) {
});
})
.catch((err) => {
notify(t("meeting.error.details", { message: err.message }));
notify(`Erreur : ${err.message}`);
event.completed();
});
}
-48
View File
@@ -1,48 +0,0 @@
const { APP_NAME } = require("../common");
const i18nextModule = require("i18next");
const i18next = i18nextModule.default || i18nextModule;
const fr = require("../locales/fr/translation.json");
const en = require("../locales/en/translation.json");
const de = require("../locales/de/translation.json");
async function initI18n() {
const lng = typeof Office !== "undefined" ? Office.context.displayLanguage : navigator.language;
await i18next.init({
lng,
fallbackLng: "fr",
interpolation: { escapeValue: false },
resources: {
fr: { translation: fr },
en: { translation: en },
de: { translation: de },
},
});
}
function t(key, vars) {
return i18next.t(key, vars);
}
function translateUI() {
document.querySelectorAll("[data-i18n]").forEach((el) => {
const key = el.getAttribute("data-i18n");
el.textContent = t(key, { app_name: APP_NAME });
});
document.querySelectorAll("[data-i18n-attr]").forEach((el) => {
const pairs = el.getAttribute("data-i18n-attr").split(",");
pairs.forEach((pair) => {
const [attr, key] = pair.split(":");
el.setAttribute(attr, t(key, { app_name: APP_NAME }));
});
});
document.querySelectorAll("[data-i18n-aria]").forEach((el) => {
el.setAttribute("aria-label", t(el.getAttribute("data-i18n-aria")));
});
}
module.exports = { initI18n, t, translateUI };
-4
View File
@@ -1,11 +1,7 @@
const BASE_URL = window.__APP_CONFIG__?.BASE_URL || "https://meet.127.0.0.1.nip.io";
const APP_NAME = window.__APP_CONFIG__?.APP_NAME || "LaSuite Meet";
const ENABLE_SOURCE_TRACKING = window.__APP_CONFIG__?.ENABLE_SOURCE_TRACKING === "true";
const FEEDBACK_FORM = window.__APP_CONFIG__?.FEEDBACK_FORM || null;
module.exports = {
BASE_URL,
APP_NAME,
ENABLE_SOURCE_TRACKING,
FEEDBACK_FORM
};
@@ -1,107 +0,0 @@
const { BASE_URL } = require("./index");
/**
* Returns a promise that resolves to true if a meeting link is already present
*/
function isMeetingAlreadyAdded(item) {
return Promise.all([_checkBody(item), _checkLocation(item)]).then(
([inBody, inLocation]) => inBody || inLocation
);
}
function _checkBody(item) {
return new Promise((resolve) => {
item.body.getAsync(Office.CoercionType.Text, (result) => {
if (result.status !== Office.AsyncResultStatus.Succeeded) {
resolve(false);
return;
}
resolve(_containsMeetingUrl(result.value));
});
});
}
function _checkLocation(item) {
// Location only exists on appointments
if (item.itemType !== Office.MailboxEnums.ItemType.Appointment) {
return Promise.resolve(false);
}
return new Promise((resolve) => {
item.location.getAsync((result) => {
if (result.status !== Office.AsyncResultStatus.Succeeded) {
resolve(false);
return;
}
resolve(_containsMeetingUrl(result.value));
});
});
}
function _containsMeetingUrl(text) {
if (!text) return false;
return text.includes(BASE_URL);
}
function removeMeetingLink(item) {
return Promise.all([_removeFromBody(item), _removeFromLocation(item)]);
}
function _removeFromBody(item) {
return new Promise((resolve) => {
item.body.getAsync(Office.CoercionType.Html, (result) => {
if (result.status !== Office.AsyncResultStatus.Succeeded) {
resolve();
return;
}
const cleaned = _cleanBody(result.value || "");
if (cleaned === null) {
resolve();
return;
}
item.body.setAsync(cleaned, { coercionType: Office.CoercionType.Html }, () => resolve());
});
});
}
function _removeFromLocation(item) {
if (item.itemType !== Office.MailboxEnums.ItemType.Appointment) {
return Promise.resolve();
}
return new Promise((resolve) => {
item.location.getAsync((result) => {
if (
result.status === Office.AsyncResultStatus.Succeeded &&
_containsMeetingUrl(result.value)
) {
item.location.setAsync("", () => resolve());
} else {
resolve();
}
});
});
}
const SEPARATOR = /─{10,}/;
/**
* Returns cleaned HTML, or null if no meeting block was found.
*/
function _cleanBody(html) {
const doc = new DOMParser().parseFromString(html, "text/html");
const hits = [];
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
while (walker.nextNode()) {
if (SEPARATOR.test(walker.currentNode.nodeValue)) hits.push(walker.currentNode);
}
if (hits.length < 2) return null;
const range = doc.createRange();
range.setStartBefore(hits[0]);
range.setEndAfter(hits[hits.length - 1]);
range.deleteContents();
return doc.documentElement.outerHTML;
}
module.exports = { isMeetingAlreadyAdded, removeMeetingLink };
+16 -43
View File
@@ -1,5 +1,4 @@
const { APP_NAME, ENABLE_SOURCE_TRACKING } = require("./index");
const { t } = require("./i18n");
const { APP_NAME } = require("./index");
function _formatPin(pin) {
if (!pin) return "";
@@ -21,59 +20,33 @@ function _formatPhone(phone) {
return clean;
}
function _appendTrackingParams(url) {
if (!ENABLE_SOURCE_TRACKING) return url;
const u = new URL(url);
u.searchParams.set("from", "outlook-addin");
return u.toString();
}
// todo - escape html / link
function buildMeetingMessage(data, isWeb) {
function buildMeetingMessage(data) {
if (!data?.url) {
throw new Error("buildMeetingMessage: missing url in data");
}
const url = _appendTrackingParams(data.url);
const url = data.url;
const phone = _formatPhone(data.telephony?.phone_number);
const pin = _formatPin(data.telephony?.pin_code);
let textLines = "";
let phoneLines = [];
const telephonyBlock =
phone && pin
? `
const join = t("meeting_message.join", { app_name: APP_NAME });
const phoneOnly = t("meeting_message.phone_only");
const phoneFr = t("meeting_message.phone_fr", { phone });
const pinCode = t("meeting_message.pin_code", { pin });
Ou appelez (audio uniquement)
(FR) ${phone}
Code : ${pin}`
: "";
if (isWeb) {
phoneLines = phone && pin ? [`<br><br>${phoneOnly}`, `<br>${phoneFr}`, `<br>${pinCode}`] : [];
const message = `<pre style="font-family:inherit; font-size:inherit; border:none; background:none; margin:16px 0;">
────────────────────────────────────────
Rejoindre la réunion ${APP_NAME}
textLines = [
"<br><br>────────────────────────────────────────",
`<br>${join}`,
`<br><br><a href="${url}" target="_blank">${url}</a>`,
...phoneLines,
"<br>────────────────────────────────────────<br>",
];
<a href="${url}">${url}</a>${telephonyBlock}
────────────────────────────────────────</pre>`;
} else {
phoneLines = phone && pin ? [`\n\n${phoneOnly}`, `\n${phoneFr}`, `\n${pinCode}`] : [];
textLines = [
"\n\n────────────────────────────────────────",
`\n${join}`,
`\n\n${url}`,
...phoneLines,
"\n────────────────────────────────────────\n",
];
}
const text = textLines.join("");
return { url, text };
return { url, message };
}
module.exports = { buildMeetingMessage };
@@ -1,40 +0,0 @@
{
"app": {
"sideload": "Laden Sie das Add-In.",
"loading": "Wird geladen..."
},
"unauth": {
"intro": "Fügen Sie Ihren Outlook-Terminen ganz einfach einen {{app_name}}-Besprechungslink hinzu.",
"proconnect_btn": "Aanmelden met ProConnect",
"proconnect_link": "Wat is ProConnect?",
"proconnect_link_title": "Wat is ProConnect? - nieuw venster"
},
"success": {
"close_window": "Falls sich dieses Fenster nicht automatisch schließt, schließen Sie es bitte manuell."
},
"auth": {
"disconnect": "Abmelden"
},
"meeting": {
"already_added": "Es wurde bereits ein {{app_name}}-Meeting hinzugefügt.",
"link_inserted": "Besprechungslink erfolgreich eingefügt",
"generating": "Wird erstellt...",
"add_meeting": "{{app_name}}-Besprechung hinzufügen",
"remove_meeting": "{{app_name}}-Besprechung entfernen",
"removing": "Wird entfernt...",
"error": {
"auth": "Ihre Sitzung ist abgelaufen. Bitte versuchen Sie es erneut.",
"retry": "Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
"details": "Fehler: {{message}}"
}
},
"meeting_message": {
"join": "An der {{app_name}}-Besprechung teilnehmen",
"phone_only": "Oder per Telefon teilnehmen (nur Audio)",
"phone_fr": "(FR) {{phone}}",
"pin_code": "Code {{pin}}"
},
"footer": {
"feedback": "Teilen Sie uns Ihr Feedback mit"
}
}
@@ -1,40 +0,0 @@
{
"app": {
"sideload": "Please load the add-in.",
"loading": "Loading..."
},
"unauth": {
"intro": "Easily add a {{app_name}} meeting link to your Outlook events.",
"proconnect_btn": "Sign in with ProConnect",
"proconnect_link": "What is ProConnect?",
"proconnect_link_title": "What is ProConnect? - new window"
},
"success": {
"close_window": "If this window does not close automatically, please close it manually."
},
"auth": {
"disconnect": "Sign out"
},
"meeting": {
"already_added": "A {{app_name}} meeting has already been added.",
"link_inserted": "Meeting link inserted successfully",
"generating": "Generating...",
"add_meeting": "Add a {{app_name}} meeting",
"remove_meeting": "Remove the {{app_name}} meeting",
"removing": "Removing...",
"error": {
"auth": "Your session has expired. Please try again.",
"retry": "An error occurred. Please try again.",
"details": "Error: {{message}}"
}
},
"meeting_message": {
"join": "Join the {{app_name}} meeting",
"phone_only": "Or call in (audio only)",
"phone_fr": "(FR) {{phone}}",
"pin_code": "Code {{pin}}"
},
"footer": {
"feedback": "Share your feedback"
}
}
@@ -1,40 +0,0 @@
{
"app": {
"sideload": "Veuillez charger le complément.",
"loading": "Chargement..."
},
"unauth": {
"intro": "Ajoutez facilement un lien de réunion {{app_name}} à vos événements Outlook.",
"proconnect_btn": "S'identifier avec ProConnect",
"proconnect_link": "Qu'est-ce que ProConnect ?",
"proconnect_link_title": "Qu'est-ce que ProConnect ? - nouvelle fenêtre"
},
"success": {
"close_window": "Si cette fenêtre ne se ferme pas toute seule, veuillez la fermer manuellement."
},
"auth": {
"disconnect": "Se déconnecter"
},
"meeting": {
"already_added": "Une réunion {{app_name}} a déjà été ajoutée.",
"link_inserted": "Lien de réunion inséré avec succès",
"generating": "Génération...",
"add_meeting": "Ajouter une réunion {{app_name}}",
"remove_meeting": "Supprimer la réunion {{app_name}}",
"removing": "Suppression en cours...",
"error": {
"auth": "Connexion expirée, veuillez réessayer.",
"retry": "Une erreur est survenue, veuillez ré-essayer",
"details": "Erreur : {{message}}"
}
},
"meeting_message": {
"join": "Rejoindre la réunion {{app_name}}",
"phone_only": "Ou appelez (audio uniquement)",
"phone_fr": "(FR) {{phone}}",
"pin_code": "Code {{pin}}"
},
"footer": {
"feedback": "Partagez-nous vos retours"
}
}
+2 -3
View File
@@ -9,10 +9,10 @@
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
</head>
<body>
<div id="sideload-msg" data-i18n="app.sideload"></div>
<div id="sideload-msg">Veuillez charger le complément.</div>
<div class="spinner-container"
role="progressbar"
data-i18n-aria="app.loading"
aria-label="Chargement..."
>
<svg class="spinner-svg"
viewBox="0 0 28 28"
@@ -40,6 +40,5 @@
</svg>
</span>
</div>
<p id="close-msg" style="display: none; text-align: center; font-size: 13px; color: #666; margin-top: 16px;" data-i18n="success.close_window"></p>
</body>
</html>
+14 -29
View File
@@ -1,35 +1,20 @@
const { applyAppName } = require("../common/helpers");
const { exchangeSession } = require("../common/api");
const { consume } = require("../common/transitToken");
const { initI18n, translateUI } = require("../common/i18n");
(async () => {
await initI18n();
applyAppName();
applyAppName();
translateUI();
const transitToken = consume();
const transitToken = consume();
if (!transitToken) {
console.error("Transit token not found in sessionStorage");
window.close();
} else {
exchangeSession(transitToken)
.then(() => {
document.querySelector(".spinner-container").style.display = "none";
document.querySelector("#close-msg").style.display = "block";
})
.catch((e) => {
console.error(`Error occured: ${e}`);
})
.finally(() => {
// NOTE: doesn't work with the desktop client — the browser considers
// this window wasn't opened by this script (it was opened externally),
// so it blocks window.close() for security reasons. The "#close-msg"
// shown above is the fallback for that case.g
window.close();
});
}
})();
if (!transitToken) {
console.error("Transit token not found in sessionStorage");
window.close();
} else {
exchangeSession(transitToken)
.catch((e) => {
console.error(`Error occured: ${e}`);
})
.finally(() => {
window.close();
});
}
+4 -48
View File
@@ -115,39 +115,15 @@ button {
background-color: #f5f5f5;
}
/* ── Danger button (remove meeting) ── */
#btn-remove {
background-color: #CA3632; /* error.400 */
color: #FFFFFF;
border: none;
}
#btn-remove:hover {
background-color: #EE6A66; /* error.600 */
}
#btn-remove:active {
background-color: #F28D8A; /* error.700 */
color: #F6AFAD; /* error.200 */
}
#btn-remove:disabled {
background-color: #F6AFAD; /* error.800 */
color: #FAD2D1; /* error.900 */
cursor: not-allowed;
}
/* ── Version ── */
#version-tag {
position: fixed;
bottom: 8px;
left: 8px;
right: 8px;
display: flex;
justify-content: space-between;
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #6b7280;
@@ -155,29 +131,9 @@ button {
pointer-events: none;
}
#feedback-link {
font-size: 11px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #6b7280;
text-decoration: underline;
pointer-events: all; /* override parent's pointer-events: none */
cursor: pointer;
}
#feedback-link:hover {
color: #374151;
}
#footer-right {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: auto;
}
.version-badge {
background: #EEF1F4;
color: #2845C1;
background: #fef3c7;
color: #92400e;
padding: 1px 6px;
border-radius: 3px;
font-weight: 600;
+16 -25
View File
@@ -10,56 +10,47 @@
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
</head>
<body>
<div id="sideload-msg" data-i18n="app.sideload"></div>
<div id="sideload-msg">Veuillez charger le complément.</div>
<div id="app-body">
<!-- Loading -->
<div id="view-loading">
<p class="intro-text" data-i18n="app.loading"></p>
<p class="intro-text">Chargement...</p>
</div>
<!-- Unauthenticated -->
<div id="view-unauth" style="display:none;">
<p class="intro-text">
<span data-i18n="unauth.intro"></span>
<span>Ajoutez facilement un lien de réunion <span data-app-name></span> à vos événements Outlook.</span>
</p>
<hr class="divider" />
<button class="proconnect-button" id="btn-connect">
<span class="proconnect-sr-only" data-i18n="unauth.proconnect_btn"></span>
<span class="proconnect-sr-only">S'identifier avec ProConnect</span>
</button>
<p>
<a href="https://www.proconnect.gouv.fr/"
target="_blank"
rel="noopener noreferrer"
data-i18n-attr="title:unauth.proconnect_link_title"
data-i18n="unauth.proconnect_link"
></a>
<a
href="https://www.proconnect.gouv.fr/"
target="_blank"
rel="noopener noreferrer"
title="Quest-ce que ProConnect ? - nouvelle fenêtre"
>
Quest-ce que ProConnect ?
</a>
</p>
</div>
<!-- Authenticated -->
<div id="view-auth" style="display:none;">
<div id="btn-container">
<!-- shown when no meeting is present -->
<button id="btn-generate" data-i18n="meeting.add_meeting"></button>
<!-- shown when a meeting is already present -->
<button id="btn-remove" style="display:none;" data-i18n="meeting.remove_meeting"></button>
<button id="btn-disconnect" data-i18n="auth.disconnect"></button>
<button id="btn-generate">Ajouter une réunion <span data-app-name></span></button>
<button id="btn-disconnect">Se déconnecter</button>
</div>
</div>
</div>
<footer id="version-tag">
<a id="feedback-link"
style="display:none;"
target="_blank"
rel="noopener noreferrer"
data-i18n="footer.feedback"
></a>
<div id="footer-right">
<span class="version-badge">beta</span>
<span class="version-number">0.0.2</span>
</div>
<span class="version-badge">alpha</span>
<span class="version-number">0.0.1</span>
</footer>
</body>
</html>
+42 -113
View File
@@ -1,77 +1,22 @@
/* global Office */
const { APP_NAME, FEEDBACK_FORM } = require("../common");
const { APP_NAME } = require("../common");
const { applyAppName } = require("../common/helpers");
const { initSession, createRoom } = require("../common/api");
const { startPolling } = require("../common/polling");
const { openTransitDialog } = require("../common/transitDialog");
const { loadSession, saveSession, clearSession } = require("../common/session");
const { buildMeetingMessage } = require("../common/messageBuilder");
const { initI18n, t, translateUI } = require("../common/i18n");
const { isMeetingAlreadyAdded, removeMeetingLink } = require("../common/meetingDetector");
// ── Views ────────────────────────────────────────────────────
// todo - support loading view while polling
// todo - support error view
function showView(name) {
document.getElementById("view-loading").style.display = "none";
document.getElementById("view-unauth").style.display = "none";
document.getElementById("view-auth").style.display = "none";
document.getElementById(`view-${name}`).style.display = "block";
if (name === "auth") {
_refreshMeetingButtonState();
}
}
// ── Button state ─────────────────────────────────────────────
function _showAddButton() {
document.getElementById("btn-generate").style.display = "block";
document.getElementById("btn-remove").style.display = "none";
}
function _showRemoveButton() {
document.getElementById("btn-generate").style.display = "none";
document.getElementById("btn-remove").style.display = "block";
}
function _setButtonLoading() {
const btn = document.getElementById("btn-generate");
btn.disabled = true;
btn.textContent = t("meeting.generating");
}
function _setButtonIdle() {
const btn = document.getElementById("btn-generate");
btn.disabled = false;
btn.textContent = t("meeting.add_meeting", { app_name: APP_NAME });
}
function _setRemoveLoading() {
const btn = document.getElementById("btn-remove");
btn.disabled = true;
btn.textContent = t("meeting.removing");
}
function _setRemoveIdle() {
const btn = document.getElementById("btn-remove");
btn.disabled = false;
btn.textContent = t("meeting.remove_meeting", { app_name: APP_NAME });
}
function _refreshMeetingButtonState() {
const item = Office.context.mailbox.item;
if (!item) return;
isMeetingAlreadyAdded(item).then((alreadyAdded) => {
if (alreadyAdded) {
_showRemoveButton();
} else {
_showAddButton();
}
});
}
// ── Auth ─────────────────────────────────────────────────────
function connect() {
initSession()
.then((data) => {
@@ -102,11 +47,22 @@ function disconnect() {
clearSession().finally(() => showView("unauth"));
}
// ── Meeting ──────────────────────────────────────────────────
function _setButtonLoading() {
const btn = document.getElementById("btn-generate");
btn.disabled = true;
btn.textContent = "Génération...";
}
function _setButtonIdle() {
const btn = document.getElementById("btn-generate");
btn.disabled = false;
btn.textContent = `Ajouter une réunion ${APP_NAME}`;
}
function generateMeetingLink() {
const session = loadSession();
if (!session?.access_token) {
console.error("Session introuvable. Veuillez vous reconnecter.");
showView("unauth");
return;
}
@@ -115,28 +71,36 @@ function generateMeetingLink() {
createRoom(session)
.then((data) => {
const isWeb = Office.context.diagnostics.platform === "OfficeOnline";
const { url, text } = buildMeetingMessage(data, isWeb);
const { url, message } = buildMeetingMessage(data);
const item = Office.context.mailbox.item;
const coercionType = isWeb ? Office.CoercionType.Html : Office.CoercionType.Text;
return new Promise((resolve, reject) => {
item.body.setSelectedDataAsync(text, { coercionType }, (setResult) => {
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
reject(setResult.error);
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
reject(getResult.error);
return;
}
if (item.itemType === Office.MailboxEnums.ItemType.Appointment) {
item.location.setAsync(url, () => resolve());
return;
}
resolve();
item.body.setAsync(
getResult.value + message,
{ coercionType: Office.CoercionType.Html },
(setResult) => {
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
reject(setResult.error);
return;
}
// ─── If calendar event, also set location ──────────────
if (item.itemType === Office.MailboxEnums.ItemType.Appointment) {
item.location.setAsync(url, () => resolve());
return;
}
resolve();
}
);
});
});
})
.then(() => {
_showRemoveButton();
})
.catch((err) => {
console.error(err);
})
@@ -145,41 +109,7 @@ function generateMeetingLink() {
});
}
function removeMeetingLinkFromItem() {
const session = loadSession();
if (!session?.access_token) {
showView("unauth");
return;
}
_setRemoveLoading();
const item = Office.context.mailbox.item;
removeMeetingLink(item)
.then(() => {
_showAddButton();
})
.catch((err) => {
console.error(err);
})
.finally(() => {
_setRemoveIdle();
});
}
// ── Init ─────────────────────────────────────────────────────
Office.onReady(async (info) => {
await initI18n();
translateUI();
if (FEEDBACK_FORM) {
const link = document.getElementById("feedback-link");
link.href = FEEDBACK_FORM;
link.style.display = "inline";
}
Office.onReady((info) => {
if (info.host === Office.HostType.Outlook) {
applyAppName();
document.getElementById("sideload-msg").style.display = "none";
@@ -187,11 +117,10 @@ Office.onReady(async (info) => {
document.getElementById("btn-connect").onclick = connect;
document.getElementById("btn-disconnect").onclick = disconnect;
document.getElementById("btn-generate").onclick = generateMeetingLink;
document.getElementById("btn-remove").onclick = removeMeetingLinkFromItem;
const session = loadSession();
if (session?.state === "authenticated" && session?.access_token) {
showView("auth"); // this already calls _refreshMeetingButtonState internally
showView("auth");
} else {
showView("unauth");
}
+2 -2
View File
@@ -10,11 +10,11 @@
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
</head>
<body>
<div id="sideload-msg" data-i18n="app.sideload"></div>
<div id="sideload-msg">Veuillez charger le complément.</div>
<div
class="spinner-container"
role="progressbar"
data-i18n-aria="app.loading"
aria-label="Chargement..."
>
<svg
class="spinner-svg"
+1 -6
View File
@@ -2,7 +2,6 @@ const { applyAppName } = require("../common/helpers");
const { URLS } = require("../common/urls");
const { save } = require("../common/transitToken");
const { DIALOG_SIGNALS } = require("../common/transitDialog");
const { initI18n, translateUI } = require("../common/i18n");
// Initiate the authentication flow, then return to the success page
function getAuthenticateUrl() {
@@ -11,11 +10,7 @@ function getAuthenticateUrl() {
return url.toString();
}
Office.onReady(async function (info) {
await initI18n();
translateUI();
Office.onReady(function (info) {
if (info.host === Office.HostType.Outlook) {
applyAppName();
}
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.21.0"
version = "1.19.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.5.13",
+1 -1
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]]
name = "agents"
version = "1.21.0"
version = "1.19.0"
source = { virtual = "." }
dependencies = [
{ name = "livekit-agents" },
+194
View File
@@ -0,0 +1,194 @@
"""Structured audit logging."""
# Audit helpers intentionally expose many optional keyword fields.
# pylint: disable=R0913,R0917
# ruff: noqa: PLR0913
import json
import logging
from datetime import datetime, timezone
from functools import partialmethod
from typing import TYPE_CHECKING, Any, Protocol
from django.http import HttpRequest
AUDIT_LOGGER_NAME = "audit"
def resolve_source_ip(request: HttpRequest):
"""Return the best-effort client IP for ``request``.
Reads the original client from ``X-Forwarded-For`` when present,
falling back to ``REMOTE_ADDR``.
NB: behind a proxy/load-balancer chain, correctness depends on the ingress
being configured to set and trust ``X-Forwarded-For``. Confirm the
trusted-proxy chain before relying on this value for security decisions.
"""
forwarded = request.META.get("HTTP_X_FORWARDED_FOR")
if forwarded:
return forwarded.split(",")[0].strip()
return request.META.get("REMOTE_ADDR")
def extract_request_fields(request: HttpRequest) -> dict[str, Any]:
"""Return the audit fields derivable from ``request``."""
meta = request.META
return {
"source_ip": resolve_source_ip(request),
"source_port": meta.get("REMOTE_PORT"),
"request_path": request.path,
"request_url": request.build_absolute_uri(),
"request_method": request.method,
"request_body_bytes": meta.get("CONTENT_LENGTH"),
"http_version": meta.get("SERVER_PROTOCOL"),
"user_agent": meta.get("HTTP_USER_AGENT"),
"referer": meta.get("HTTP_REFERER"),
"server_user": meta.get("REMOTE_USER"),
}
class AuditJsonFormatter(logging.Formatter):
"""Render audit records as single-line JSON.
Read the structured payload attached to the record under ``audit`` and
wrap it in a small envelope.
"""
def format(self, record):
payload = {"log_type": "audit"}
audit = getattr(record, "audit", None)
if isinstance(audit, dict):
payload.update(audit)
else:
payload["event_type"] = record.getMessage()
payload.setdefault(
"timestamp",
datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
)
payload["level"] = record.levelname
payload["logger"] = record.name
# ``default=str`` serialises UUIDs, datetimes, etc.; ``ensure_ascii``
# off keeps emails and non-ASCII identifiers readable.
return json.dumps(payload, default=str, ensure_ascii=False)
class _AuditEmit(Protocol):
"""Public signature shared by the per-level audit methods.
Declared so editors and type checkers see the real keyword fields despite using `partialwrapper`.
"""
def __call__(
self,
event_type: str,
*,
auth_type: str | None = ...,
actor: dict[str, Any] | None = ...,
source_ip: str | None = ...,
target: dict[str, Any] | None = ...,
request: HttpRequest | None = ...,
**extra: Any,
) -> None:
pass
class AuditLogger:
"""Wrapper around the named ``audit`` logger."""
def __init__(
self,
logger_name=AUDIT_LOGGER_NAME,
custom_serializers: list[tuple] | None = None,
):
self._logger = logging.getLogger(logger_name)
self._serializers = []
if custom_serializers is not None:
self._serializers = custom_serializers
def _emit(
self,
level,
event_type,
request: HttpRequest | None, # Intentionnaly mandatory
*,
exc_info: bool = False,
**extra,
):
"""Assemble the structured payload and emit it on the audit logger.
``exc_info`` is forwarded to the stdlib logger (set by ``exception``) so
the active traceback is captured; it is a logging concern and never
enters the audit payload.
"""
request_fields = extract_request_fields(request) if request is not None else {}
# Create fields from custom serializers
new_extra = {}
for key, value in extra.items():
for cls, serializer in self._serializers:
if isinstance(value, cls):
new_extra = new_extra | {
f"{key}.{ser_key}": ser_field
for ser_key, ser_field in serializer(value).items()
}
break
else:
new_extra[key] = value
audit = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"event_type": event_type,
**request_fields,
**new_extra,
}
audit = {key: value for key, value in audit.items() if value is not None}
self._logger.log(level, event_type, extra={"audit": audit}, exc_info=exc_info)
# One public method per standard logging level, all sharing ``_emit``.
# The ``TYPE_CHECKING`` declarations expose the real signature to editors;
# the ``else`` branch is what runs, binding the level via ``partialmethod``.
if TYPE_CHECKING:
debug: _AuditEmit
info: _AuditEmit
warning: _AuditEmit
error: _AuditEmit
critical: _AuditEmit
# ``exception`` mirrors stdlib: ERROR level with the active traceback.
exception: _AuditEmit
else:
debug = partialmethod(_emit, logging.DEBUG)
info = partialmethod(_emit, logging.INFO)
warning = partialmethod(_emit, logging.WARNING)
error = partialmethod(_emit, logging.ERROR)
critical = partialmethod(_emit, logging.CRITICAL)
exception = partialmethod(_emit, logging.ERROR, exc_info=True)
def getLogger(
name: str | None = None, custom_serializers: list[tuple | None] = None
) -> AuditLogger:
"""Return an :class:`AuditLogger`, mirroring :func:`logging.getLogger`.
Pass ``__name__`` to tag audit records with the calling module while still
emitting on the dedicated ``audit`` handler::
from core import audit
logger = audit.getLogger(__name__)
logger.info("external_api.token.issued", ...)
Names are nested under ``AUDIT_LOGGER_NAME`` (e.g. ``audit.core.foo``) so
they inherit its handlers through the standard logging hierarchy, keeping
the module visible in the ``logger`` field of the emitted JSON.
"""
if not name or name == AUDIT_LOGGER_NAME:
return AuditLogger(AUDIT_LOGGER_NAME, custom_serializers=custom_serializers)
return AuditLogger(
f"{AUDIT_LOGGER_NAME}.{name}", custom_serializers=custom_serializers
)
+19
View File
@@ -0,0 +1,19 @@
"""Audit logging with custom serializers."""
from core.audit import getLogger as get_logger_base
from core.models import Room
def serialize_room(room: Room):
return {
"name": room.name,
"slug": room.slug,
"access_level": room.access_level,
}
custom_serializers = [(Room, serialize_room)]
def getLogger(name: str | None = None):
return get_logger_base(name=name, custom_serializers=custom_serializers)
+13 -12
View File
@@ -1,7 +1,5 @@
"""External API endpoints"""
from logging import getLogger
from django.conf import settings
from django.contrib.auth.hashers import check_password
from django.core.exceptions import ValidationError
@@ -21,6 +19,7 @@ from rest_framework import (
from core import api, models
from core.api.feature_flag import FeatureFlag
from core.audit_meet import getLogger
from core.services.jwt_token import JwtTokenService
from ..services.provisional_user_service import (
@@ -30,7 +29,7 @@ from ..services.provisional_user_service import (
)
from . import authentication, permissions, serializers
logger = getLogger(__name__)
audit_logger = getLogger(__name__)
class ApplicationViewSet(viewsets.ViewSet):
@@ -86,10 +85,11 @@ class ApplicationViewSet(viewsets.ViewSet):
)
if not application.can_delegate_email(email):
logger.warning(
"Application %s denied delegation for %s",
application.client_id,
email,
audit_logger.warning(
"Application denied delegation",
request=request,
application_client_id=application.client_id,
email=email,
)
return drf_response.Response(
{
@@ -195,9 +195,10 @@ class RoomViewSet(
)
# Log for auditing
logger.info(
"Room created via application: room_id=%s, user_id=%s, client_id=%s",
room.id,
self.request.user.id,
getattr(self.request.auth, "client_id", "unknown"),
audit_logger.info(
"room_created_via_application",
request=self.request,
# Extra
room=room,
client_id=getattr(self.request.auth, "client_id", "unknown"),
)
+206
View File
@@ -0,0 +1,206 @@
"""
Test audit.py
"""
# pylint: disable=W0621,redefined-outer-name
import json
import logging
from django.test import RequestFactory
import pytest
from core.audit import AuditJsonFormatter, extract_request_fields, getLogger
class ExampleRoomModel:
"""Example for an audited object."""
def __init__(self, name, slug, access_level):
self.name = name
self.slug = slug
self.access_level = access_level
def serialize_example_room(room):
"""Flatten a ``ExampleRoomModel`` into the audit fields a service would expose."""
return {
"name": room.name,
"slug": room.slug,
"access_level": room.access_level,
}
# Audit logger wired with the local serializer, mirroring how a service
# registers its own object serializers.
test_audit_logger = getLogger(
"core.tests.test_audit_2",
custom_serializers=[(ExampleRoomModel, serialize_example_room)],
)
class _CaptureHandler(logging.Handler):
"""Collect every record routed to the ``audit`` logger tree."""
def __init__(self):
super().__init__()
self.records = []
def emit(self, record):
self.records.append(record)
@pytest.fixture
def audit_records():
"""Yield audit records, capturing children via propagation to ``audit``.
The viewsets log on ``audit.core.external_api.viewsets``; attaching to the
root ``audit`` logger captures those propagated records as well as ones
emitted directly on it.
"""
handler = _CaptureHandler()
logger = logging.getLogger("audit")
logger.addHandler(handler)
previous_level = logger.level
logger.setLevel(logging.DEBUG)
try:
yield handler.records
finally:
logger.removeHandler(handler)
logger.setLevel(previous_level)
def _payloads(records, event_type):
"""Return the audit payloads matching ``event_type``."""
return [r.audit for r in records if r.audit.get("event_type") == event_type]
# ---------------------------------------------------------------------------
# core.audit - custom serializer flattening
# ---------------------------------------------------------------------------
def test_room_extra_is_flattened_via_custom_serializer(audit_records):
"""A ``Room`` keyword is expanded into dotted ``room.<field>`` keys."""
room = ExampleRoomModel(
name="Talking About Vacation",
slug="talking-about-vacations",
access_level="restricted",
)
test_audit_logger.info("event", request=None, room=room)
audit = audit_records[0].audit
assert audit["room.name"] == "Talking About Vacation"
assert audit["room.slug"] == "talking-about-vacations"
assert audit["room.access_level"] == "restricted"
# The raw object is replaced by its serialized fields, not kept.
assert "room" not in audit
def test_non_registered_extras_pass_through_unchanged(audit_records):
"""Values without a matching serializer are kept verbatim."""
room = ExampleRoomModel(
name="Back To Work", slug="back-to-work", access_level="public"
)
test_audit_logger.info(
"event",
request=None,
room=room,
client_id="test-id",
target={"user_id": "u1"},
)
audit = audit_records[0].audit
assert audit["client_id"] == "test-id"
# A dict has no registered serializer, so it stays nested as-is.
assert audit["target"] == {"user_id": "u1"}
# The Room alongside it is still flattened.
assert audit["room.name"] == "Back To Work"
# ---------------------------------------------------------------------------
# core.audit - getLogger naming & request field extraction
# ---------------------------------------------------------------------------
def test_getlogger_nests_module_name_under_audit():
"""A named logger sits under ``audit.`` so it inherits its handlers."""
assert getLogger("core.foo")._logger.name == "audit.core.foo"
def test_getlogger_without_name_uses_base_audit_logger():
"""Empty ``audit`` names resolve to the bare ``audit`` logger."""
assert getLogger()._logger.name == "audit"
assert getLogger("audit")._logger.name == "audit"
def test_extract_request_fields_collects_request_metadata():
"""All request-derived fields are populated from request META."""
request = RequestFactory().post(
"/external-api/v1.0/rooms/",
REMOTE_ADDR="10.0.0.5",
REMOTE_PORT="54321",
HTTP_USER_AGENT="pytest-agent",
HTTP_REFERER="https://example.test/from",
HTTP_X_FORWARDED_FOR="203.0.113.7, 10.0.0.5",
)
fields = extract_request_fields(request)
# X-Forwarded-For wins over REMOTE_ADDR for the client IP.
assert fields["source_ip"] == "203.0.113.7"
assert fields["source_port"] == "54321"
assert fields["request_path"] == "/external-api/v1.0/rooms/"
assert fields["request_method"] == "POST"
assert fields["user_agent"] == "pytest-agent"
assert fields["referer"] == "https://example.test/from"
assert fields["request_url"].endswith("/external-api/v1.0/rooms/")
# ---------------------------------------------------------------------------
# core.audit - exception level & JSON rendering of flattened fields
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"method_name,expected_level",
[
("debug", logging.DEBUG),
("info", logging.INFO),
("warning", logging.WARNING),
("error", logging.ERROR),
("critical", logging.CRITICAL),
],
)
def test_level_is_passed_to_record(audit_records, method_name, expected_level):
"""Each per-level method emits a record carrying that level."""
getattr(test_audit_logger, method_name)("event", request=None)
record = audit_records[0]
assert record.levelno == expected_level
assert record.levelname == method_name.upper()
def test_level_is_rendered_in_json_payload(audit_records):
"""The JSON formatter surfaces the record level under ``level``."""
test_audit_logger.warning("event", request=None)
rendered = json.loads(AuditJsonFormatter().format(audit_records[0]))
assert rendered["level"] == "WARNING"
def test_exception_logs_error_with_active_traceback(audit_records):
"""``exception`` emits at ERROR and captures the active traceback."""
try:
raise ValueError("boom")
except ValueError:
test_audit_logger.exception("operation.failed", request=None)
record = audit_records[0]
assert record.levelno == logging.ERROR
# exc_info is the live traceback tuple, never part of the audit payload.
assert record.exc_info is not None
assert "exc_info" not in record.audit
+34 -6
View File
@@ -411,12 +411,6 @@ class Base(Configuration):
"transcription_destination": values.Value(
None, environ_name="FRONTEND_TRANSCRIPTION_DESTINATION", environ_prefix=None
),
"max_participants_for_sound": values.PositiveIntegerValue(
5, environ_name="FRONTEND_MAX_PARTICIPANTS_FOR_SOUND", environ_prefix=None
),
"auto_mute_on_join_threshold": values.PositiveIntegerValue(
50, environ_name="FRONTEND_AUTO_MUTE_ON_JOIN_THRESHOLD", environ_prefix=None
),
}
# Mail
@@ -1031,12 +1025,22 @@ class Base(Configuration):
"format": "{asctime} {name} {levelname} {message}",
"style": "{",
},
"audit_json": {
"()": "core.audit.AuditJsonFormatter",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "simple",
},
# Audit events go to stdout on their own handler so they stay
# distinguishable from regular application logs.
"audit_console": {
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
"formatter": "audit_json",
},
},
# Override root logger to send it to console
"root": {
@@ -1055,6 +1059,15 @@ class Base(Configuration):
),
"propagate": False,
},
"audit": {
"handlers": ["audit_console"],
"level": values.Value(
"INFO",
environ_name="LOGGING_LEVEL_LOGGERS_AUDIT",
environ_prefix=None,
),
"propagate": False,
},
},
}
@@ -1164,16 +1177,31 @@ class Test(Base):
{
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"audit_json": {
"()": "core.audit.AuditJsonFormatter",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
},
"audit_console": {
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
"formatter": "audit_json",
},
},
"loggers": {
"meet": {
"handlers": ["console"],
"level": "DEBUG",
},
"audit": {
"handlers": ["audit_console"],
"level": "INFO",
"propagate": False,
},
},
}
)
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.21.0"
version = "1.19.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
+1 -1
View File
@@ -1187,7 +1187,7 @@ wheels = [
[[package]]
name = "meet"
version = "1.21.0"
version = "1.19.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
-2
View File
@@ -45,8 +45,6 @@ USER root
# Security patches for known CVEs
RUN apk update && apk upgrade \
libcrypto3>=3.5.7-r0 \
libssl3>=3.5.7-r0 \
musl \
musl-utils \
zlib>=1.3.2-r0 \
+13 -25
View File
@@ -1,17 +1,16 @@
{
"name": "meet",
"version": "1.21.0",
"version": "1.19.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.21.0",
"version": "1.19.0",
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
"@fontsource-variable/lexend": "5.2.11",
"@fontsource/opendyslexic": "5.2.5",
"@libreaudio/la-call": "0.1.4",
"@livekit/components-react": "2.9.21",
"@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.2",
@@ -34,7 +33,7 @@
"react": "18.3.1",
"react-aria-components": "1.14.0",
"react-dom": "18.3.1",
"react-i18next": "17.0.8",
"react-i18next": "15.1.1",
"use-sound": "5.0.0",
"valtio": "2.3.2",
"wouter": "3.10.0"
@@ -339,9 +338,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -1447,12 +1446,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@libreaudio/la-call": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@libreaudio/la-call/-/la-call-0.1.4.tgz",
"integrity": "sha512-yaqlYGpvZbfpr1h2p1jSGOKPUy7NsfpoJN9gXvHmJu5MB8nN/qfx6LQ2a0hbua3H8rargFKROOv4VZ5WuTKNsA==",
"license": "GPL-3.0+"
},
"node_modules/@livekit/components-core": {
"version": "0.12.13",
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.13.tgz",
@@ -11242,19 +11235,17 @@
}
},
"node_modules/react-i18next": {
"version": "17.0.8",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.1.1.tgz",
"integrity": "sha512-R/Vg9wIli2P3FfeI8o1eNJUJue5LWpFsQePCHdQDmX0Co3zkr6kdT8gAseb/yGeWbNz1Txc4bKDQuZYsC0kQfw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
"@babel/runtime": "^7.25.0",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
"typescript": "^5 || ^6"
"i18next": ">= 23.2.3",
"react": ">= 16.8.0"
},
"peerDependenciesMeta": {
"react-dom": {
@@ -11262,9 +11253,6 @@
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
+2 -3
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.21.0",
"version": "1.19.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -17,7 +17,6 @@
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
"@fontsource-variable/lexend": "5.2.11",
"@libreaudio/la-call": "0.1.4",
"@fontsource/opendyslexic": "5.2.5",
"@livekit/components-react": "2.9.21",
"@livekit/components-styles": "1.2.0",
@@ -41,7 +40,7 @@
"react": "18.3.1",
"react-aria-components": "1.14.0",
"react-dom": "18.3.1",
"react-i18next": "17.0.8",
"react-i18next": "15.1.1",
"use-sound": "5.0.0",
"valtio": "2.3.2",
"wouter": "3.10.0"
-2
View File
@@ -55,8 +55,6 @@ export interface ApiConfig {
default_sources: Source[]
}
transcription_destination?: string
max_participants_for_sound: number
auto_mute_on_join_threshold: number
}
const fetchConfig = (): Promise<ApiConfig> => {
+3 -19
View File
@@ -5,16 +5,6 @@ import { type ApiUser } from './ApiUser'
import { useMemo } from 'react'
import { useConfig } from '@/api/useConfig'
const SILENT_LOGIN_PARAM = 'silentLogin'
const isSilentLoginDisabledByUrl = () => {
if (typeof window === 'undefined') return false
const value = new URLSearchParams(window.location.search).get(
SILENT_LOGIN_PARAM
)
return value === 'false'
}
/**
* returns info about currently logged-in user
*
@@ -27,22 +17,16 @@ export const useUser = (
) => {
const { data, isLoading: isConfigLoading } = useConfig()
const disabledByUrl = useMemo(() => isSilentLoginDisabledByUrl(), [])
const options = useMemo(() => {
if (isConfigLoading) return
const silentDisabled =
data?.is_silent_login_enabled !== true || disabledByUrl
if (silentDisabled) {
if (data?.is_silent_login_enabled !== true) {
return {
...opts.fetchUserOptions,
...opts,
attemptSilent: false,
}
}
return opts.fetchUserOptions
}, [data, opts, isConfigLoading, disabledByUrl])
}, [data, opts, isConfigLoading])
const query = useQuery({
queryKey: [keys.user],
@@ -14,11 +14,9 @@ import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { Emoji } from '@/features/reactions/types'
import { useReactions } from '@/features/reactions/hooks/useReactions'
import { NotificationProvider } from './NotificationProvider'
import { useConfig } from '@/api/useConfig'
export const MainNotificationToast = () => {
const room = useRoomContext()
const { data } = useConfig()
const { triggerNotificationSound } = useNotificationSound()
const { t } = useTranslation('notifications')
const announce = useScreenReaderAnnounce()
@@ -137,21 +135,12 @@ export const MainNotificationToast = () => {
}
}, [room, handleEmoji])
const triggerNotificationSoundIfRoomIsSmall = useCallback(
(type: NotificationType) => {
if (!data) return
if (room.numParticipants >= data.max_participants_for_sound) return
triggerNotificationSound(type)
},
[room, data, triggerNotificationSound]
)
useEffect(() => {
const showJoinNotification = (participant: Participant) => {
if (isMobileBrowser()) {
return
}
triggerNotificationSoundIfRoomIsSmall(NotificationType.ParticipantJoined)
triggerNotificationSound(NotificationType.ParticipantJoined)
toastQueue.add(
{
participant,
@@ -166,7 +155,7 @@ export const MainNotificationToast = () => {
return () => {
room.off(RoomEvent.ParticipantConnected, showJoinNotification)
}
}, [room, triggerNotificationSoundIfRoomIsSmall])
}, [room, triggerNotificationSound])
useEffect(() => {
const removeParticipantNotifications = (participant: Participant) => {
@@ -1,5 +1,4 @@
export enum NotificationType {
AutoMuteLargeRoom = 'autoMuteLargeRoom',
ParticipantJoined = 'participantJoined',
HandRaised = 'handRaised',
ParticipantMuted = 'participantMuted',
@@ -1,54 +0,0 @@
import { useToast } from '@react-aria/toast'
import { useRef } from 'react'
import { type ToastProps } from './Toast'
import { VStack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
import { Button } from '@/primitives'
import { css } from '@/styled-system/css'
import { StyledToastContainer } from './StyledToastContainer'
import { useRoomContext } from '@livekit/components-react'
export function ToastAutoMuteLargeRoom({
state,
...props
}: Readonly<ToastProps>) {
const room = useRoomContext()
const { t } = useTranslation('notifications', {
keyPrefix: 'autoMuteLargeRoom',
})
const ref = useRef(null)
const { toastProps, contentProps } = useToast(props, state, ref)
const toast = props.toast
const handleDismiss = async () => {
room.localParticipant
.setMicrophoneEnabled(true)
.finally(() => state.close(toast.key))
}
return (
<StyledToastContainer {...toastProps} ref={ref}>
<VStack
justify="start"
alignItems="self-start"
{...contentProps}
maxWidth="370px"
gap="0.75rem"
padding={14}
>
<p>{t('auto')}</p>
<Button
size="sm"
variant="text"
className={css({
color: 'primary.300',
})}
onPress={() => handleDismiss()}
>
{t('dismiss')}
</Button>
</VStack>
</StyledToastContainer>
)
}
@@ -13,7 +13,6 @@ import { ToastAnyRecording } from './ToastAnyRecording'
import { ToastRecordingSaving } from './ToastRecordingSaving'
import { ToastPermissionsRemoved } from './ToastPermissionsRemoved'
import { ToastRecordingRequest } from './ToastRecordingRequest'
import { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom'
interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<ToastData>
@@ -46,11 +45,6 @@ const renderToast = (
case NotificationType.LowerHand:
return <ToastLowerHand key={toast.key} toast={toast} state={state} />
case NotificationType.AutoMuteLargeRoom:
return (
<ToastAutoMuteLargeRoom key={toast.key} toast={toast} state={state} />
)
case NotificationType.TranscriptionStarted:
case NotificationType.TranscriptionStopped:
case NotificationType.TranscriptionLimitReached:
@@ -5,15 +5,6 @@ import type { Participant } from 'livekit-client'
import type { NotificationPayload } from './NotificationPayload'
import type { RecordingMode } from '@/features/recording'
export const notifyAutoMutedOnJoin = () => {
toastQueue.add(
{
type: NotificationType.AutoMuteLargeRoom,
},
{ timeout: NotificationDuration.ALERT }
)
}
export const showLowerHandToast = (
participant: Participant,
onClose: () => void
@@ -6,5 +6,5 @@ export const useIsMetadataCollectorEnabled = () => {
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.metadataCollector)
const isAnalyticsEnabled = useIsAnalyticsEnabled()
return !isAnalyticsEnabled || Boolean(featureEnabled)
return (featureEnabled && isAnalyticsEnabled) || true
}
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
@@ -33,9 +33,6 @@ import { useIsMobile } from '@/utils/useIsMobile'
import { navigateTo } from '@/navigation/navigateTo'
import { connectionObserverStore } from '@/stores/connectionObserver'
import { PictureInPictureConference } from '@/features/pip/components/PictureInPictureConference'
import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
import { useSnapshot } from 'valtio'
import { userPreferencesStore } from '@/stores/userPreferences'
export const Conference = ({
roomId,
@@ -60,8 +57,6 @@ export const Conference = ({
const [isConnectionWarmedUp, setIsConnectionWarmedUp] = useState(false)
const userPreferencesSnap = useSnapshot(userPreferencesStore)
const {
mutateAsync: createRoom,
status: createStatus,
@@ -178,8 +173,6 @@ export const Conference = ({
const isMobile = useIsMobile()
const hasAutoMutedRef = useRef(false)
/*
* Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility
* (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in WebSocket() constructor
@@ -235,19 +228,6 @@ export const Conference = ({
onError={(e) => {
posthog.captureException(e)
}}
onConnected={async () => {
if (!apiConfig) return
if (
userPreferencesSnap.is_auto_mute_large_room_enabled &&
!hasAutoMutedRef.current &&
userConfig.audioEnabled &&
room.numParticipants > apiConfig.auto_mute_on_join_threshold
) {
hasAutoMutedRef.current = true
await room.localParticipant.setMicrophoneEnabled(false)
notifyAutoMutedOnJoin()
}
}}
onDisconnected={(e) => {
const metadata = {
room_id: roomId,
@@ -216,14 +216,6 @@ export const Join = ({
try {
const track = await createLocalAudioTrack({
deviceId: { exact: audioDeviceId },
noiseSuppression: true,
echoCancellation: true,
autoGainControl: true,
voiceIsolation: false,
// Audio quality optimized for voice
sampleRate: 48000, // High quality sample rate
channelCount: 1, // Mono for voice calls (saves bandwidth)
sampleSize: 16, // 16-bit audio
})
setDynamicAudioTrack(track)
} catch (error) {
@@ -400,8 +400,6 @@ export const EffectsConfiguration = ({
config: ProcessorConfig
isSelected: boolean
tooltip: string
ariaLabel: string
ariaDeleteLabel: string
file: ApiFileItem
}[]
}>(() => {
@@ -428,9 +426,7 @@ export const EffectsConfiguration = ({
const id = deriveIdFromProcessorConfig(config)
return {
id,
tooltip: t(
`blur.${item.key}.${selectedId === id ? 'clear' : 'apply'}`
),
tooltip: t(`blur.light.${selectedId === id ? 'clear' : 'apply'}`),
radius: item.radius,
isSelected: selectedId === id,
Icon: item.icon,
@@ -447,11 +443,9 @@ export const EffectsConfiguration = ({
}
const id = deriveIdFromProcessorConfig(config)
const isSelected = selectedId === id
const prefix = isSelected ? 'selectedLabel' : 'apply'
const backgroundName = t(`virtual.presets.descriptions.${index}`)
const ariaLabelPrefix = t(
isSelected ? `virtual.selectedLabel` : `virtual.apply`
)
const ariaLabel = `${ariaLabelPrefix} ${backgroundName}`
const ariaLabel = `${t(`virtual.presets.${prefix}`)} ${backgroundName}`
return {
tooltip: backgroundName,
@@ -473,22 +467,13 @@ export const EffectsConfiguration = ({
}
const id = deriveIdFromProcessorConfig(config)
const isSelected = selectedId === id
const ariaLabel = t(
isSelected
? `virtual.personal.selectedLabel`
: `virtual.personal.apply`
)
const ariaDeleteLabel = t('virtual.personal.deleteLabel')
return {
tooltip: file.title,
id,
config,
isSelected,
isSelected: selectedId === id,
file,
ariaLabel,
ariaDeleteLabel,
}
}),
}
@@ -567,7 +552,6 @@ export const EffectsConfiguration = ({
[layout === 'vertical' ? 'height' : 'minHeight']: '175px',
borderRadius: '8px',
}}
aria-hidden={true}
/>
) : (
<div
@@ -680,7 +664,7 @@ export const EffectsConfiguration = ({
})}
>
<H
lvl={3}
lvl={2}
style={{
marginBottom: '0.4rem',
}}
@@ -696,7 +680,6 @@ export const EffectsConfiguration = ({
paddingBottom: '0.5rem',
flexWrap: 'wrap',
})}
role="list"
>
{createFileMutation.isPending &&
fileBeingUploadedObjectUrlRef.current && (
@@ -738,54 +721,48 @@ export const EffectsConfiguration = ({
className={
'hoverGroup ' + css({ position: 'relative' })
}
role="listitem"
>
<div role="group" aria-label={option.file.title}>
<VisualOnlyTooltip tooltip={option.tooltip}>
<ToggleButton
variant="bigSquare"
aria-label={option.ariaLabel}
isDisabled={processorOptions.isDisabled}
onChange={getHandleSelectChangeFile(
option.file
)}
isSelected={option.isSelected}
className={css({
bgSize: 'cover',
})}
style={{
backgroundImage: `url(${option.file.url!})`,
}}
data-attr={`toggle-virtual-${option.file.id}`}
/>
</VisualOnlyTooltip>
<Button
className={
'hoverGroupChild ' +
css({
position: 'absolute',
top: '-8px',
right: '-8px',
transition: 'opacity 0.2s ease-in-out',
})
}
aria-label={option.ariaDeleteLabel}
size={'xs'}
variant={'tertiary'}
onClick={() => {
if (option.isSelected) {
// we remove the current effect
toggleEffect(option.config)
}
deleteFileMutation.mutate({
fileId: option.file.id,
})
<VisualOnlyTooltip tooltip={option.tooltip}>
<ToggleButton
variant="bigSquare"
aria-label={option.tooltip}
isDisabled={processorOptions.isDisabled}
onChange={getHandleSelectChangeFile(option.file)}
isSelected={option.isSelected}
className={css({
bgSize: 'cover',
})}
style={{
backgroundImage: `url(${option.file.url!})`,
}}
isDisabled={deleteFileMutation.isPending}
>
<RiDeleteBinLine size={16} />
</Button>
</div>
data-attr={`toggle-virtual-${option.file.id}`}
/>
</VisualOnlyTooltip>
<Button
className={
'hoverGroupChild ' +
css({
position: 'absolute',
top: '-8px',
right: '-8px',
transition: 'opacity 0.2s ease-in-out',
})
}
size={'xs'}
variant={'tertiary'}
onClick={() => {
if (option.isSelected) {
// we remove the current effect
toggleEffect(option.config)
}
deleteFileMutation.mutate({
fileId: option.file.id,
})
}}
isDisabled={deleteFileMutation.isPending}
>
<RiDeleteBinLine size={16} />
</Button>
</div>
)
)}
@@ -798,13 +775,9 @@ export const EffectsConfiguration = ({
>
<ToggleButton
variant="bigSquare"
aria-label={`${t(
deriveIdFromProcessorConfig(
uploadNotPossibleSnap.imageBackgroundConfig
) === selectedId
? `virtual.selectedLabel`
: `virtual.apply`
)} ${uploadNotPossibleSnap.imageBackgroundConfig.label}`}
aria-label={
uploadNotPossibleSnap.imageBackgroundConfig.label
}
isDisabled={
processorOptions.isDisabled ||
createFileMutation.isPending
@@ -884,7 +857,7 @@ export const EffectsConfiguration = ({
})}
>
<H
lvl={3}
lvl={2}
style={{
marginBottom: '0.4rem',
}}
@@ -899,27 +872,24 @@ export const EffectsConfiguration = ({
paddingBottom: '0.5rem',
flexWrap: 'wrap',
})}
role="list"
>
{processorOptions.virtualBackgrounds.map((option) => (
<div role="listitem" key={option.id}>
<VisualOnlyTooltip tooltip={option.tooltip}>
<ToggleButton
variant="bigSquare"
aria-label={option.ariaLabel}
isDisabled={processorOptions.isDisabled}
onChange={() => toggleEffect(option.config)}
isSelected={option.isSelected}
className={css({
bgSize: 'cover',
})}
style={{
backgroundImage: `url(${option.thumbnailPath})`,
}}
data-attr={`toggle-virtual-preset-${option.index}`}
/>
</VisualOnlyTooltip>
</div>
<VisualOnlyTooltip key={option.id} tooltip={option.tooltip}>
<ToggleButton
variant="bigSquare"
aria-label={option.ariaLabel}
isDisabled={processorOptions.isDisabled}
onChange={() => toggleEffect(option.config)}
isSelected={option.isSelected}
className={css({
bgSize: 'cover',
})}
style={{
backgroundImage: `url(${option.thumbnailPath})`,
}}
data-attr={`toggle-virtual-preset-${option.index}`}
/>
</VisualOnlyTooltip>
))}
</div>
</div>
@@ -1,30 +1,14 @@
import type { Track, TrackProcessor, ProcessorOptions } from 'livekit-client'
import { NoiseSuppressorWorklet_Name } from '@timephy/rnnoise-wasm'
// This is an example how to get the script path using Vite, may be different when using other build tools
// NOTE: `?worker&url` is important (`worker` to generate a working script, `url` to get its url to load it)
import NoiseSuppressorWorklet from '@timephy/rnnoise-wasm/NoiseSuppressorWorklet?worker&url'
// Use Jitsi's approach: maintain a global AudioContext variable
// and suspend/resume it as needed to manage audio state
let audioContext: AudioContext
/**
* Lazily load the WASM processor factory.
*
* '@libreaudio/la-call' pulls in a WebAssembly payload, so we defer importing
* it until a processor is actually initialized rather than at module load.
* The import promise is cached so repeated init() calls reuse the same module.
*/
type CreateWasmProcessor =
(typeof import('@libreaudio/la-call'))['createWasmProcessor']
let wasmProcessorPromise: Promise<CreateWasmProcessor> | undefined
function loadWasmProcessor(): Promise<CreateWasmProcessor> {
if (!wasmProcessorPromise) {
wasmProcessorPromise = import('@libreaudio/la-call').then(
(mod) => mod.createWasmProcessor
)
}
return wasmProcessorPromise
}
export interface AudioProcessorInterface extends TrackProcessor<Track.Kind.Audio> {
name: string
}
@@ -36,7 +20,7 @@ export class RnnNoiseProcessor implements AudioProcessorInterface {
private source?: MediaStreamTrack
private sourceNode?: MediaStreamAudioSourceNode
private destinationNode?: MediaStreamAudioDestinationNode
private noiseSuppressionNode?: AudioNode
private noiseSuppressionNode?: AudioWorkletNode
async init(opts: ProcessorOptions<Track.Kind.Audio>) {
if (!opts.track) {
@@ -51,17 +35,16 @@ export class RnnNoiseProcessor implements AudioProcessorInterface {
await audioContext.resume()
}
await audioContext.audioWorklet.addModule(NoiseSuppressorWorklet)
this.sourceNode = audioContext.createMediaStreamSource(
new MediaStream([this.source])
)
const createWasmProcessor = await loadWasmProcessor()
this.noiseSuppressionNode = await createWasmProcessor(audioContext, {
intensity: 90,
})
if (!this.noiseSuppressionNode) {
throw new Error('Failed to create Wasm processor')
}
this.noiseSuppressionNode = new AudioWorkletNode(
audioContext,
NoiseSuppressorWorklet_Name
)
this.destinationNode = audioContext.createMediaStreamDestination()
@@ -39,19 +39,6 @@ export const GeneralTab = ({ id }: GeneralTabProps) => {
fullWidth: true,
}}
/>
<Field
type="switch"
label={t('preferences.autoMuteLargeRoom.label')}
description={t('preferences.autoMuteLargeRoom.description')}
isSelected={userPreferencesSnap.is_auto_mute_large_room_enabled}
onChange={(value) =>
(userPreferencesStore.is_auto_mute_large_room_enabled = value)
}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
/>
</TabPanel>
)
}
+1 -16
View File
@@ -14,7 +14,6 @@ import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { useLoginHint } from '@/hooks/useLoginHint'
import { logout } from '@/features/auth/utils/logout'
import { useMemo } from 'react'
const Logo = () => (
<img
@@ -85,16 +84,6 @@ const LoginHint = () => {
)
}
const HIDE_LOGIN_PARAM = 'hideLogin'
const isLoginButtonHidden = () => {
if (typeof window === 'undefined') return false
const value = new URLSearchParams(window.location.search).get(
HIDE_LOGIN_PARAM
)
return value === 'true'
}
export const Header = () => {
const { t } = useTranslation()
const isHome = useMatchesRoute('home')
@@ -103,9 +92,6 @@ export const Header = () => {
const isTermsOfService = useMatchesRoute('termsOfService')
const isRoom = useMatchesRoute('room')
const { user, isLoggedIn } = useUser()
const loginButtonDisabledByUrl = useMemo(() => isLoginButtonHidden(), [])
const userLabel = user?.full_name || user?.email
const loggedInTooltip = t('loggedInUserTooltip')
const loggedInAriaLabel = userLabel
@@ -166,8 +152,7 @@ export const Header = () => {
!isHome &&
!isLegalTerms &&
!isAccessibility &&
!isTermsOfService &&
!loginButtonDisabledByUrl && (
!isTermsOfService && (
<>
<LoginButton proConnectHint={false} />
<LoginHint />
@@ -14,10 +14,6 @@
"auto": "Du hast begonnen zu sprechen deine Hand wird daher automatisch gesenkt.",
"dismiss": "Hand oben lassen"
},
"autoMuteLargeRoom": {
"auto": "Sie haben an einer großen Besprechung teilgenommen. Ihr Mikrofon wurde automatisch stummgeschaltet.",
"dismiss": "Stummschaltung aufheben"
},
"reaction": {
"description": "{{name}} hat mit {{emoji}} reagiert"
},
-3
View File
@@ -290,14 +290,11 @@
"apply": "Ersetze deinen Hintergrund:",
"personal": {
"title": "Meine Hintergründe",
"selectedLabel": "Hintergrund abwählen (er ist aktuell angewendet).",
"apply": "Als Hintergrund verwenden.",
"selectFileTooltip": "Wähle ein Bild aus, das als persönlicher Hintergrund verwendet werden soll",
"notLoggedInWarning": "Du bist nicht angemeldet, der persönliche Hintergrund wird nicht von einem Meeting zum anderen gespeichert.",
"warningUploadDisabled": "Persönliche Hintergründe werden derzeit nicht von einem Meeting zum anderen gespeichert.",
"uploadLimitReached": "Du kannst keine weiteren persönlichen Hintergründe hinzufügen.",
"uploadInProgress": "Datei wird hochgeladen…",
"deleteLabel": "Benutzerdefinierten Hintergrund löschen.",
"errors": {
"close": "Schließen",
"file_too_large": {
@@ -12,10 +12,6 @@
"idleDisconnectModal": {
"label": "Meetings ohne Teilnehmende verlassen",
"description": "Meetings nach einigen Minuten automatisch verlassen, wenn keine andere Person beitritt"
},
"autoMuteLargeRoom": {
"label": "Mikrofon automatisch stummschalten",
"description": "Dein Mikrofon wird automatisch stummgeschaltet, wenn du an einer großen Besprechung teilnimmst."
}
},
"audio": {
@@ -14,10 +14,6 @@
"auto": "It seems you have started speaking, so your hand will be lowered.",
"dismiss": "Keep hand raised"
},
"autoMuteLargeRoom": {
"auto": "You have joined a large meeting. Your microphone has been automatically muted.",
"dismiss": "Unmute"
},
"reaction": {
"description": "{{name}} reacted with {{emoji}}"
},
-3
View File
@@ -290,14 +290,11 @@
"apply": "Replace your background:",
"personal": {
"title": "My backgrounds",
"selectedLabel": "Unselect background (it's currently applied).",
"apply": "Use as background.",
"selectFileTooltip": "Select an image file to use as a personal background",
"notLoggedInWarning": "You are not logged-in, personal backgrounds won't be saved from one meeting to the other.",
"warningUploadDisabled": "Personal backgrounds are currently not saved from one meeting to the other.",
"uploadLimitReached": "You cannot upload more personal backgrounds.",
"uploadInProgress": "Image is being uploaded…",
"deleteLabel": "Delete background.",
"errors": {
"close": "Close",
"file_too_large": {
+2 -6
View File
@@ -12,10 +12,6 @@
"idleDisconnectModal": {
"label": "Leave calls with no participants",
"description": "Automatically leaves a call after a few minutes if no other participant joins"
},
"autoMuteLargeRoom": {
"label": "Automatically mute my microphone",
"description": "Automatically mute your microphone when joining a large meeting."
}
},
"audio": {
@@ -25,8 +21,8 @@
"disabled": "Microphone disabled"
},
"noiseReduction": {
"label": "Advanced voice optimization",
"heading": "Audio filters",
"label": "Noise reduction",
"heading": "Noise reduction",
"ariaLabel": {
"enable": "Enable noise reduction",
"disable": "Disable noise reduction"
@@ -14,10 +14,6 @@
"auto": "Il semblerait que vous ayez pris la parole, donc la main va être baissée.",
"dismiss": "Laisser la main levée"
},
"autoMuteLargeRoom": {
"auto": "Vous venez de rejoindre une grande réunion. Votre micro a été automatiquement coupé.",
"dismiss": "Rétablir le micro"
},
"reaction": {
"description": "{{name}} a reagi avec {{emoji}}"
},
-3
View File
@@ -290,14 +290,11 @@
"apply": "Remplacer votre arrière plan :",
"personal": {
"title": "Mes arrière-plans",
"selectedLabel": "Ne plus utiliser comme arrière-plan (actuellement actif).",
"apply": "Utiliser comme arrière plan.",
"selectFileTooltip": "Sélectionnez une image à utiliser comme arrière-plan",
"notLoggedInWarning": "Vous n'êtes pas connecté, l'arrière-plan personnel ne sera pas sauvegardé d'une réunion à l'autre.",
"warningUploadDisabled": "Les arrière-plans personnels ne sont actuellement pas sauvegardés d'une réunion à l'autre.",
"uploadLimitReached": "Vous ne pouvez pas ajouter plus d'arrière-plans personnels.",
"uploadInProgress": "Image en cours d'envoi…",
"deleteLabel": "Supprimer l'arrière-plan.",
"errors": {
"close": "Fermer",
"file_too_large": {
@@ -12,10 +12,6 @@
"idleDisconnectModal": {
"label": "Quitter les appels sans autre participant",
"description": "Vous fait quitter un appel au bout de quelques minutes si aucun autre participant ne vous rejoint"
},
"autoMuteLargeRoom": {
"label": "Couper automatiquement mon micro",
"description": "Coupe automatiquement votre micro lorsque vous rejoignez une réunion de grande taille."
}
},
"audio": {
@@ -14,10 +14,6 @@
"auto": "Het lijkt erop dat u bent begonnen te spreken, dus we laten uw hand zakken.",
"dismiss": "Houdt uw hand opgestoken"
},
"autoMuteLargeRoom": {
"auto": "U bent zojuist lid geworden van een grote vergadering. Uw microfoon is automatisch gedempt.",
"dismiss": "Microfoon inschakelen"
},
"reaction": {
"description": "{{name}} reageerde met {{emoji}}"
},
-3
View File
@@ -290,14 +290,11 @@
"apply": "Vervang je achtergrond:",
"personal": {
"title": "Mijn achtergronden",
"selectedLabel": "Deselecteer de achtergrond (deze is momenteel toegepast).",
"apply": "Gebruik als achtergrond.",
"selectFileTooltip": "Selecteer een afbeeldingsbestand om te gebruiken als persoonlijke achtergrond",
"notLoggedInWarning": "U bent niet ingelogd, persoonlijke achtergronden worden niet opgeslagen van de ene vergadering naar de andere.",
"warningUploadDisabled": "Persoonlijke achtergronden worden momenteel niet opgeslagen van de ene vergadering naar de andere.",
"uploadLimitReached": "U kunt geen persoonlijke achtergronden meer uploaden.",
"uploadInProgress": "Afbeelding wordt geüpload…",
"deleteLabel": "Aangepaste achtergrond verwijderen.",
"errors": {
"close": "Sluiten",
"file_too_large": {
@@ -12,10 +12,6 @@
"idleDisconnectModal": {
"label": "Verlaat oproepen zonder deelnemers",
"description": "Verlaat automatisch een oproep na een paar minuten als geen andere deelnemer meedoet"
},
"autoMuteLargeRoom": {
"label": "Mijn microfoon automatisch dempen",
"description": "Uw microfoon wordt automatisch gedempt wanneer u deelneemt aan een grote vergadering."
}
},
"audio": {
@@ -3,12 +3,10 @@ import { STORAGE_KEYS } from '@/utils/storageKeys'
type State = {
is_idle_disconnect_modal_enabled: boolean
is_auto_mute_large_room_enabled: boolean
}
const DEFAULT_STATE = {
is_idle_disconnect_modal_enabled: true,
is_auto_mute_large_room_enabled: true,
}
function getUserPreferencesState(): State {
@@ -23,9 +23,6 @@ frontend:
- name: outlook-addon-manifest
configMap:
name: outlook-addon-manifest
- name: frontend-nginx-config
configMap:
name: frontend-nginx-config
extraVolumeMounts:
- name: outlook-addon-config
@@ -36,18 +33,9 @@ frontend:
mountPath: /usr/share/nginx/html/addons/outlook/manifest.xml
subPath: manifest.xml
readOnly: true
- name: frontend-nginx-config
mountPath: /etc/nginx/conf.d/default.conf
subPath: default.conf
readOnly: true
outlookAddon:
enabled: true
baseUrl: "https://meet.127.0.0.1.nip.io"
enableSourceTracking: true
feedbackForm: "https://tests.com/"
appName: "Visio"
id: "a025f0f6-757a-4790-97f3-99c66c4a5795"
frontendNginxConfig:
enabled: true
@@ -53,6 +53,3 @@ agentSubtitles:
items:
- key: cacert.pem
path: cert.pem
frontendNginxConfig:
enabled: false
@@ -1,94 +0,0 @@
{{- if .Values.frontendNginxConfig.enabled }}
apiVersion: v1
kind: ConfigMap
metadata:
name: frontend-nginx-config
namespace: {{ .Release.Namespace }}
data:
default.conf: |
server {
listen 8080;
server_name localhost;
server_tokens off;
root /usr/share/nginx/html;
location = /.well-known/windows-app-web-link {
default_type application/json;
alias /usr/share/nginx/html/.well-known/windows-app-web-link;
add_header Content-Disposition "attachment; filename=windows-app-web-link";
}
# Manifest — fetched, never iframed
location = /addons/outlook/manifest.xml {
alias /usr/share/nginx/html/addons/outlook/manifest.xml;
add_header Access-Control-Allow-Origin "*";
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header X-Frame-Options "DENY";
add_header Content-Security-Policy "frame-ancestors 'none'";
}
location = /addons/outlook/assets/ {
return 404;
}
location ~* ^/addons/outlook/assets/(.+\.(?:css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot))/?$ {
root /usr/share/nginx/html;
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable" always;
add_header Access-Control-Allow-Origin "*";
add_header Vary "Origin" always;
}
location = /addons/outlook/ {
return 404;
}
location ~ ^/addons/outlook(/.*)?$ {
alias /usr/share/nginx/html/addons/outlook$1;
error_page 404 =200 /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache" always;
add_header Expires 0 always;
set $ms_domains "https://*.live.com https://*.office.com https://*.microsoft.com https://*.office365.com https://*.sharepoint.com";
set $nonce $request_id;
set $csp "default-src 'self'; upgrade-insecure-requests; ";
set $csp "${csp}frame-ancestors ${ms_domains}; ";
set $csp "${csp}script-src 'nonce-${nonce}' 'strict-dynamic'; ";
set $csp "${csp}style-src 'self' 'unsafe-inline'; ";
set $csp "${csp}img-src 'self' data:; ";
set $csp "${csp}font-src 'self' data:; ";
set $csp "${csp}connect-src 'self' ${ms_domains}; ";
set $csp "${csp}frame-src 'none'; ";
set $csp "${csp}object-src 'none'; ";
set $csp "${csp}base-uri 'none'; ";
add_header Content-Security-Policy $csp;
sub_filter 'NONCE_PLACEHOLDER' $nonce;
sub_filter_once off;
}
# Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# Serve static files
location / {
try_files $uri $uri/ /index.html;
# Add no-cache headers
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache"; # HTTP 1.0 header for backward compatibility
add_header Expires 0;
}
# Optionally, handle 404 errors by redirecting to index.html
error_page 404 =200 /index.html;
}
{{- end }}
+1 -1
View File
@@ -1,4 +1,4 @@
apiVersion: v2
type: application
name: meet
version: 0.0.23
version: 0.0.22
+13 -32
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0" xsi:type="MailApp">
<Id>{{ .id }}</Id>
<Version>0.0.2.0</Version>
<Version>0.0.1.0</Version>
<ProviderName>{{ .appName }}</ProviderName>
<DefaultLocale>fr-FR</DefaultLocale>
<DisplayName DefaultValue="{{ .appName }}"/>
@@ -87,9 +87,9 @@
<Description resid="GenerateLink.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
<bt:Image size="16" resid="Add.16x16"/>
<bt:Image size="32" resid="Add.32x32"/>
<bt:Image size="80" resid="Add.80x80"/>
</Icon>
<Action xsi:type="ExecuteFunction">
<FunctionName>generateMeetingLinkFromMail</FunctionName>
@@ -126,9 +126,9 @@
<Description resid="GenerateLink.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
<bt:Image size="16" resid="Add.16x16"/>
<bt:Image size="32" resid="Add.32x32"/>
<bt:Image size="80" resid="Add.80x80"/>
</Icon>
<Action xsi:type="ExecuteFunction">
<FunctionName>generateMeetingLinkFromCalendar</FunctionName>
@@ -175,34 +175,15 @@
<bt:Url id="Taskpane.Url" DefaultValue="{{ .baseUrl }}/taskpane.html"/>
</bt:Urls>
<bt:ShortStrings>
<!-- Default (French) -->
<bt:String id="GroupLabel" DefaultValue="{{ .appName }}"/>
<bt:String id="GenerateLink.Label" DefaultValue="Ajouter un lien {{ .appName }}">
<bt:Override Locale="en-US" Value="Add a {{ .appName }} link"/>
<bt:Override Locale="de-DE" Value="{{ .appName }}-Link hinzufügen"/>
</bt:String>
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir les paramètres">
<bt:Override Locale="en-US" Value="Open settings"/>
<bt:Override Locale="de-DE" Value="Einstellungen öffnen"/>
</bt:String>
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres">
<bt:Override Locale="en-US" Value="Settings"/>
<bt:Override Locale="de-DE" Value="Einstellungen"/>
</bt:String>
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir les paramètres"/>
<bt:String id="GenerateLink.Label" DefaultValue="Ajouter un lien {{ .appName }}"/>
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres"/>
</bt:ShortStrings>
<bt:LongStrings>
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion {{ .appName }} et l'insère dans l'événement.">
<bt:Override Locale="de-DE" Value="Generiert einen {{ .appName }}-Besprechungslink und fügt ihn in den Termin ein."/>
<bt:Override Locale="en-US" Value="Generates a {{ .appName }} meeting link and inserts it into the item."/>
</bt:String>
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre les paramètres de connexion {{ .appName }}.">
<bt:Override Locale="de-DE" Value="Öffnet die {{ .appName }}-Verbindungseinstellungen."/>
<bt:Override Locale="en-US" Value="Opens the {{ .appName }} connection settings."/>
</bt:String>
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion {{ .appName }}.">
<bt:Override Locale="de-DE" Value="Öffnet die {{ .appName }}-Verbindungseinstellungen."/>
<bt:Override Locale="en-US" Value="Opens the {{ .appName }} connection settings."/>
</bt:String>
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre les paramètres de connexion {{ .appName }}."/>
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion {{ .appName }} et l'insère dans l'événement."/>
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion {{ .appName }}."/>
</bt:LongStrings>
</Resources>
</VersionOverrides>
@@ -8,8 +8,6 @@ data:
config.js: |
window.__APP_CONFIG__ = {
BASE_URL: {{ .Values.frontend.outlookAddon.baseUrl | quote }},
APP_NAME: {{ .Values.frontend.outlookAddon.appName | quote }},
ENABLE_SOURCE_TRACKING: {{ .Values.frontend.outlookAddon.enableSourceTracking | quote }},
FEEDBACK_FORM: {{ .Values.frontend.outlookAddon.feedbackForm | quote }}
APP_NAME: {{ .Values.frontend.outlookAddon.appName | quote }}
};
{{- end }}
-5
View File
@@ -448,17 +448,12 @@ frontend:
## @param frontend.outlookAddon.enabled Enable the Outlook add-in ConfigMap (config.js + manifest.xml)
## @param frontend.outlookAddon.appName Display name used in the Outlook add-in (manifest + runtime config)
## @param frontend.outlookAddon.baseUrl Public base URL where the add-in is served (no trailing slash)
## @param frontend.outlookAddon.enableSourceTracking Enable appending ?from=outlook-addon to the URL
## @param frontend.outlookAddon.feedbackForm Add a link to the footer opening a feedback form
outlookAddon:
enabled: false
appName: ""
baseUrl: ""
enableSourceTracking: false
feedbackForm: ""
id: ""
## @section posthog
posthog:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.21.0",
"version": "1.19.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.21.0",
"version": "1.19.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.21.0",
"version": "1.19.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "1.21.0",
"version": "1.19.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.21.0",
"version": "1.19.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.21.0",
"version": "1.19.0",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.21.0"
version = "1.19.0"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",
-10
View File
@@ -233,7 +233,6 @@ def format_transcript(
recording_datetime: str | None,
owner_timezone: str | None,
download_link: str | None,
form_link: str | None,
) -> tuple[str, str]:
"""Format a transcription into readable content with a title.
@@ -251,7 +250,6 @@ def format_transcript(
recording_datetime=recording_datetime,
owner_timezone=owner_timezone,
download_link=download_link,
form_link=form_link,
)
@@ -348,13 +346,6 @@ def process_audio_transcribe_summarize_v2(
task_id,
)
form_base_url = settings.transcription_satisfaction_form_base_url
form_link = (
f"{form_base_url}?room_id={room}"
if (form_base_url and metadata_filename is not None)
else None
)
# Format output
content, title = format_transcript(
transcription,
@@ -364,7 +355,6 @@ def process_audio_transcribe_summarize_v2(
recording_start_at,
owner_timezone,
download_link,
form_link,
)
submit_content(content, title, email, sub)
-1
View File
@@ -122,7 +122,6 @@ class Settings(BaseSettings):
# Summary related settings
is_summary_enabled: bool = True
transcription_satisfaction_form_base_url: Optional[str] = None
# Sentry
sentry_is_enabled: bool = False
-4
View File
@@ -25,10 +25,6 @@ Einige Punkte, die wir Ihnen empfehlen zu überprüfen:
download_header_template=(
"\n*[Laden Sie hier Ihre Aufnahme herunter (externer Link)]({download_link})*\n"
),
form_footer_template=(
"\n\n*[Teilen Sie uns mit, wie Ihnen diese Transkription gefallen hat "
"(externer Link)]({form_link})*\n"
),
hallucination_replacement_text="[Text konnte nicht transkribiert werden]",
document_default_title="Transkription",
document_title_template=(
-4
View File
@@ -25,10 +25,6 @@ A few things we recommend you check:
download_header_template=(
"\n*[Download your recording (external link)]({download_link})*\n"
),
form_footer_template=(
"\n\n*[Tell us what you thought of this transcription "
"(external link)]({form_link})*\n"
),
hallucination_replacement_text="[Unable to transcribe text]",
document_default_title="Transcription",
document_title_template=(
-3
View File
@@ -25,9 +25,6 @@ Quelques points que nous vous conseillons de vérifier :
download_header_template=(
"\n*[Télécharger votre enregistrement Audio]({download_link})*\n"
),
form_footer_template=(
"\n\n*[Donnez nous votre avis sur cette transcription]({form_link})*\n"
),
hallucination_replacement_text="[Texte impossible à transcrire]",
document_default_title="Transcription",
document_title_template=(
-4
View File
@@ -25,10 +25,6 @@ Een paar punten die wij u aanraden te controleren:
download_header_template=(
"\n*[Download hier je opname (externe link)]({download_link})*\n"
),
form_footer_template=(
"\n\n*[Laat ons weten wat je van deze transcriptie vond "
"(externe link)]({form_link})*\n"
),
hallucination_replacement_text="[Tekst kon niet worden getranscribeerd]",
document_default_title="Transcriptie",
document_title_template=(
@@ -10,7 +10,6 @@ class LocaleStrings:
# transcript_formatter.py
empty_transcription: str
download_header_template: str
form_footer_template: str
hallucination_replacement_text: str
document_default_title: str
document_title_template: str
@@ -38,14 +38,13 @@ class TranscriptFormatter:
return None
def format( # noqa: PLR0913
def format(
self,
transcription,
room: str | None = None,
recording_datetime: str | None = None,
owner_timezone: str | None = None,
download_link: str | None = None,
form_link: str | None = None,
) -> Tuple[str, str]:
"""Format transcription into the final document and its title."""
segments = self._get_segments(transcription)
@@ -56,8 +55,6 @@ class TranscriptFormatter:
content = self._format_speaker(segments)
content = self._remove_hallucinations(content)
content = self._add_header(content, download_link)
if form_link:
content = self._add_footer(content, form_link)
title = self._generate_title(room, recording_datetime, owner_timezone)
@@ -96,14 +93,9 @@ class TranscriptFormatter:
header = self._locale.download_header_template.format(
download_link=download_link
)
return header + content
content = header + content
def _add_footer(self, content, form_link: str | None):
if not form_link:
return content
footer = self._locale.form_footer_template.format(form_link=form_link)
return content + footer
return content
def _generate_title(
self,
-1
View File
@@ -27,7 +27,6 @@ class TestTasks:
"owner_timezone": "UTC",
"language": None,
"download_link": "https://example.com/file.mp4",
"form_link": "https://satisfaction.com?room_id=test",
},
)
+1850
View File
File diff suppressed because it is too large Load Diff