У меня много встреч в гугломите, бывает что и по 3-4 часа в день. Недавно Google добавил туда кучу новых эмодзи для реакций и мы активно используем их. Только UX для поиска - так себе. Периодически коллеги шлют новое прикольное эмодзи, а я слаб с их поиском по названиям и совсем затрудняюсь найти вот ту самую картинку, что использовал коллега.
Конечно увлекающийся программист может извратить улучшить любой UX! Результат - расширение Google Meet Reactions, которое добавляет мгновенный поиск прямо в интерфейс Meet. А самое главное для меня - оно запоминает, какие эмодзи использую я, и какие шлют коллеги - и поднимает их выше в результатах.
От UI к WebTRC
Первая мысль была простая: найти кнопки эмодзи в DOM, симулировать клик. Но Google Meet - конечно же обфусцирован с классами вроде .b1bzTb или .VfPpkd-rymPhb, да и полный список эмодзи искать в дебрях попапов - так себе идея.
Затем я открыл chrome://webrtc-internals во время звонка и увидел интересное: среди десятков RTCDataChannel’ов есть один с названием “reactions” и оказалось, что эмодзи шлют именно туда. Если можно получить ссылку на этот канал и расшифровать структуру сообщений, то можно и самому слать туда реакции.

Перехват создания DataChannel
WebRTC DataChannel создаётся через RTCPeerConnection.prototype.createDataChannel(). Просто модифицируем этот метод до того, как код Meet его вызовет и сохраняем ссылку.
const origCreate = RTCPeerConnection.prototype.createDataChannel;
RTCPeerConnection.prototype.createDataChannel = function(label, options) {
const channel = origCreate.call(this, label, options);
if (label === 'reactions') {
console.log('Gotcha!');
capturedChannel = channel;
}
return channel;
};
Идея простая, но есть небольшая проблема с внедрением кода.
Внедрение до того как канал начнёт использоваться
Chrome-расширения могут внедрять код в страницу несколькими способами. Content script запускается в изолированном мире и не имеет доступа к RTCPeerConnection страницы. Нужно инжектить скрипт прямо в page context.
Стандартный способ:
const script = document.createElement('script');
script.src = chrome.runtime.getURL('rtcHook.js');
document.documentElement.appendChild(script);
Но script.src = URL требует сетевого запроса. За это время Meet может уже создать канал “reactions”, и мой хук его пропустит.
Решение - следующая комбинация:
runAt: 'document_start'в манифесте - content script запускается до загрузки DOM- Скрипт хука объявлен как
web_accessible_resourceи загружается как можно раньше
// wxt.config.ts
export default defineConfig({
manifest: {
web_accessible_resources: [{
resources: ['rtcHook.js'],
matches: ['https://meet.google.com/*'],
}],
},
});
В большинстве случаев хук успевает установиться до создания канала Meet’ом. Полагаю, race condition всё ещё возможен, и для этого у меня есть fallback UI с сообщением “обновите страницу”, но на практике я ни разу такого не наблюдал.
Отправка эмодзи
Когда канал перехвачен и открыт (channel.readyState === 'open'), можно слушать входящие сообщения, и что интереснее - слушать то, что сам отправляешь
Формат сообщений - оказался не очень сложным protobuf с вложенными length-delimited полями. Собираем и посылаем так:
function buildEmojiMessage(emoji) {
const emojiBytes = [...new TextEncoder().encode(emoji)];
// Собираем nested protobuf изнутри наружу - примитивно скопировали перехваченный подход
// 10 = field 1, wire type 2 (length-delimited)
// 8 = field 1, wire type 0 (varint), значение 2
const i1 = [10, emojiBytes.length, ...emojiBytes];
const i2 = [10, i1.length, ...i1];
const i3 = [8, 2, 18, i2.length, ...i2];
const i4 = [10, i3.length, ...i3];
return new Uint8Array([10, i4.length, ...i4]);
}
// Отправка - одна строка
capturedChannel.send(buildEmojiMessage('🔥').buffer);
Self-injection
Когда отправляешь эмодзи хотелось бы чтобы его видели не только коллеги, но и сам тоже. Настоящий Google Meet для этого конечно использует внутренний код, но оказывается что можно просто вызвать обработчик приходящих WebRTC сообщений от фейкового пользователя с ненастоящими ID.
// Вроде бы никакой настоящий пользователь не использует device 0, так что ничему не помешает
const SELF_DEVICE_ID = 'spaces/self/devices/0';
// Выглядит так как если бы послал сообщение через обычные кнопки, по крайней мере в англоязычном интерфейсе.
const SELF_USER_NAME = 'You';
function buildFullEmojiMessage(emoji) {
const emojiBytes = [...new TextEncoder().encode(emoji)];
const deviceIdBytes = [...new TextEncoder().encode(SELF_DEVICE_ID)];
const userNameBytes = [...new TextEncoder().encode(SELF_USER_NAME)];
// Emoji block: [0a LL emoji] [10 01]
const emojiBlock = [0x0a, emojiBytes.length, ...emojiBytes, 0x10, 0x01];
// Field 4 (device info): [0a LL deviceId] [10 01]
const field4Content = [0x0a, deviceIdBytes.length, ...deviceIdBytes, 0x10, 0x01];
const field4 = [0x22, field4Content.length, ...field4Content];
// Field 6 (user info): [0a LL deviceId] [12 LL userName] [18 01]
const field6Content = [
0x0a, deviceIdBytes.length, ...deviceIdBytes,
0x12, userNameBytes.length, ...userNameBytes,
0x18, 0x01,
];
const field6 = [0x32, field6Content.length, ...field6Content];
// Build nested structure
const emojiContainer = [0x0a, emojiBlock.length, ...emojiBlock];
const innerContent = [...emojiContainer, ...field4, ...field6];
const inner = [0x0a, innerContent.length, ...innerContent];
const outer = [0x0a, inner.length, ...inner];
return new Uint8Array(outer);
}
async function injectToSelf(emoji) {
if (!capturedChannel) return false;
const fullMessage = buildFullEmojiMessage(emoji);
// Compress with gzip
const cs = new CompressionStream('gzip');
const cw = cs.writable.getWriter();
cw.write(fullMessage);
cw.close();
const cr = cs.readable.getReader();
const chunks = [];
while (true) {
const { done, value } = await cr.read();
if (done) break;
chunks.push(value);
}
const compressed = new Uint8Array(chunks.flatMap((c) => [...c]));
// Wrap with [18, length, gzip_data] (field 2, length-delimited)
const wrapped = new Uint8Array([18, compressed.length, ...compressed]);
const fakeEvent = new MessageEvent('message', { data: wrapped.buffer });
// И клиент гугломита обработает ивент как если бы реакцию послал кто-то другой
capturedChannel.dispatchEvent(fakeEvent);
return true;
}
И ещё чтоб можно было набирать ткест с опечатками!
Сложно остановиться лишь на паре самых нужных фич и финальное расширение умеет следующее:
- Поиск по 1900+ эмодзи с исправлением опечаток(!): BM25 + Levenshtein distance
- Персонализация: эмодзи, которые использую я или шлют коллеги, поднимаются выше и я наконец-то могу посылать те самые реакции, что и коллеги
- Работает в Picture-in-Picture режиме
К сожалению, расширение работает только в митингах с Google Workspace аккаунтами, где включены Extended Reactions. Если митинг создан с личного Gmail - будут доступны только стандартные 9 эмодзи и расширение не имеет большого смысла.
Полезно?
Если используете Google Meet по работе, попробуйте. Как любому автору велосипеда мне, конечно, очень интересно чего бы такого полезного ещё добавить или бесполезного убавить. Что работает хорошо, а чего бы ещё улучшить?
Ссылки
- Google Meet Reactions - установить расширение
- googlemeetreactions.com - сайт расширения
- Страница проекта - чуть подробнее о проекте
