From f35637794648af94b4c11cdf10ff31caeac52308 Mon Sep 17 00:00:00 2001 From: Jorge Amaral <345837474+JorisPortugal@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:15:50 +0100 Subject: [PATCH 1/3] =?UTF-8?q?Chamadas=20de=20voz=20e=20v=C3=ADdeo=20em?= =?UTF-8?q?=20grupos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Os botões de chamada da conversa de grupo agora iniciam uma chamada de voz ou de vídeo pelo mesmo caminho dos botões do WhatsApp Web: startWAWebVoipGroupCallFromChat, com CALL_FROM_UI.GROUP_CHAT_INFO e LOBBY_ENTRY_POINT_TYPE.GROUP_CHAT_INFO. O patch do servidor expõe POST /api/:session/call/group/offer; o endpoint passado é "group/offer", sem duplicar o prefixo /call. Uma oferta de grupo recebida passa a ser atendível pelo mesmo caminho nativo acceptCall das chamadas individuais, com o mesmo botão, atalho global e monitor de áudio. O registro da chamada ativa usa o JID do grupo, nunca o do participante que tocou ou falou, para que os eventos de estado de qualquer participante continuem a corresponder à chamada. A enumeração de câmeras passa a aceitar a listagem do FFmpeg incluído no WinZapp, que não imprime o cabeçalho "DirectShow video devices" e marca cada dispositivo com (video)/(audio). Sem isso, uma câmera real era reportada como "No camera found" e o vídeo saía apenas a receber. Testes: tests/test_call_control_api_patch.py, test_call_hotkeys.py, test_call_video.py, test_call_video_device_settings.py, test_call_video_upgrade.py, test_incoming_call_alert.py, test_incoming_call_answer_without_video.py, test_outgoing_call_teardown.py e test_sync_voice_call_button.py. --- .../src/controller/callController.ts | 96 +++++++- client/api_patches/src/routes/index.ts | 6 + client/changelog_en-US.txt | 4 + client/changelog_es-ES.txt | 4 + client/changelog_pl.txt | 4 + client/changelog_pt-BR.txt | 4 + client/changelog_pt-PT.txt | 4 + client/changelog_ro.txt | 4 + client/changelog_tr-TR.txt | 4 + client/core/call_logic.py | 16 +- client/core/call_video.py | 33 ++- client/main_window/calls.py | 208 +++++++++++------- client/ui/conversation_panel/composer.py | 5 +- tests/test_call_control_api_patch.py | 89 +++++++- tests/test_call_hotkeys.py | 18 +- tests/test_call_video.py | 62 +++++- tests/test_incoming_call_alert.py | 151 +++++++++---- ...test_incoming_call_answer_without_video.py | 23 ++ tests/test_outgoing_call_teardown.py | 3 +- tests/test_sync_voice_call_button.py | 17 +- 20 files changed, 595 insertions(+), 160 deletions(-) diff --git a/client/api_patches/src/controller/callController.ts b/client/api_patches/src/controller/callController.ts index feb9ca29d..95e293d0c 100644 --- a/client/api_patches/src/controller/callController.ts +++ b/client/api_patches/src/controller/callController.ts @@ -17,6 +17,7 @@ import { type CallActionPayload = { callId?: string; to?: string; + groupJid?: string; isVideo?: boolean; }; @@ -478,7 +479,56 @@ async function evaluateWppCall(req: Request, action: string, payload: CallAction return null; }; - if (action === 'offer') { + // A group call has no single peer to dial. WhatsApp's own group-info + // call buttons go through WAWebVoipStartCall.startWAWebVoipGroupCallFromChat + // with the CALL_FROM_UI.GROUP_CHAT_INFO / LOBBY_ENTRY_POINT_TYPE.GROUP_CHAT_INFO + // entry points: it preloads the group-call bundle, opens the lobby and + // invites the group. The resulting call then lives in CallStore exactly + // like a one-to-one one, which is why the polling below is shared. + // Never hand-roll group signaling here. + const startOutgoingGroupCall = async ( + groupJid: string, + isVideo: boolean + ): Promise => { + const requireWa = (name: string): any => { + try { + return win.require?.(name); + } catch (_) { + return null; + } + }; + const start = + win.WPP?.whatsapp?.functions?.startWAWebVoipGroupCallFromChat || + requireWa('WAWebVoipStartCall')?.startWAWebVoipGroupCallFromChat; + if (typeof start !== 'function') { + throw new Error('startWAWebVoipGroupCallFromChat is not available'); + } + const createWid = requireWa('WAWebWidFactory')?.createWid; + const chats = requireWa('WAWebChatCollection')?.ChatCollection; + if (typeof createWid !== 'function' || typeof chats?.get !== 'function') { + throw new Error('WhatsApp chat collection is not available'); + } + const chat = chats.get(createWid(groupJid)); + if (!chat || !chat.groupMetadata) { + throw new Error(`The group ${groupJid} is not a callable group chat`); + } + const callFromUi = + requireWa('WAWebWamEnumCallFromUi')?.CALL_FROM_UI?.GROUP_CHAT_INFO ?? 10; + const lobbyEntryPoint = + requireWa('WAWebWamEnumLobbyEntryPointType')?.LOBBY_ENTRY_POINT_TYPE + ?.GROUP_CHAT_INFO ?? 11; + await start(chat, isVideo, callFromUi, lobbyEntryPoint); + return null; + }; + + // Current WhatsApp Web promotes the real ongoing call through + // CallStore.activeCall. The value returned by the legacy collection + // lookup may point at an older call model, so prefer a newly-active call + // and only trust a direct return value when it is not stale. + const waitForOfferedCall = async ( + place: () => Promise, + kind: string + ): Promise => { let call: any = null; const storeBeforeOffer = getCallStore(); const preexistingIds = new Set( @@ -488,12 +538,8 @@ async function evaluateWppCall(req: Request, action: string, payload: CallAction for (let attempt = 0; attempt < 3 && !call; attempt += 1) { await ensureVoipRuntimeReady(); - const offered = await startOutgoingCall(String(payload.to), !!payload.isVideo); + const offered = await place(); - // Current WhatsApp Web promotes the real ongoing call through - // CallStore.activeCall. The value returned by the legacy collection - // lookup may point at an older call model, so prefer a newly-active - // call and only trust the direct return value when it is not stale. for (let waitAttempt = 0; waitAttempt < 50 && !call; waitAttempt += 1) { const store = getCallStore(); const active = store?.activeCall; @@ -525,7 +571,23 @@ async function evaluateWppCall(req: Request, action: string, payload: CallAction } if (!call && attempt < 2) await delay(300 * (attempt + 1)); } - if (!call) throw new Error('WhatsApp did not create an outgoing call model'); + if (!call) throw new Error(`WhatsApp did not create ${kind} call model`); + return call; + }; + + if (action === 'offer') { + const call = await waitForOfferedCall( + () => startOutgoingCall(String(payload.to), !!payload.isVideo), + 'an outgoing' + ); + return summarizeCall(call); + } + + if (action === 'group-offer') { + const call = await waitForOfferedCall( + () => startOutgoingGroupCall(String(payload.groupJid), !!payload.isVideo), + 'an outgoing group' + ); return summarizeCall(call); } @@ -656,6 +718,26 @@ export async function offerCall(req: Request, res: Response) { } } +export async function offerGroupCall(req: Request, res: Response) { + try { + const body = req.body || {}; + if (!body.groupJid) { + res.status(400).json({ status: 'error', message: 'Parameter groupJid is required!' }); + return; + } + await prepareAudioBridge(req); + const stopWatching = watchOutgoingCallNotices(req); + try { + ok(res, await evaluateWppCall(req, 'group-offer', body)); + } finally { + stopWatching(); + } + } catch (error) { + await stopAudioBridge(req); + fail(req, res, 'offerGroupCall', error); + } +} + /** * While an outgoing call is being placed, WhatsApp Web can open a notice the * hidden page has no one to answer -- seen live calling a WhatsApp Business diff --git a/client/api_patches/src/routes/index.ts b/client/api_patches/src/routes/index.ts index 087886eeb..3dcbe0bc7 100644 --- a/client/api_patches/src/routes/index.ts +++ b/client/api_patches/src/routes/index.ts @@ -867,6 +867,12 @@ routes.post( statusConnection, CallController.offerCall ); +routes.post( + '/api/:session/call/group/offer', + verifyToken, + statusConnection, + CallController.offerGroupCall +); routes.get( '/api/:session/call/diagnostics', verifyToken, diff --git a/client/changelog_en-US.txt b/client/changelog_en-US.txt index 7ab7c08f7..e0c99f23a 100644 --- a/client/changelog_en-US.txt +++ b/client/changelog_en-US.txt @@ -4,6 +4,10 @@ This release brings communities and a reorganized settings window, on top of eve WHAT'S NEW +- Group calls: the voice and video call buttons now appear in a group conversation and start a call to the group, and an incoming group call rings, is announced by the group's name and can be answered, with the same Answer button and global answer key as a one-to-one call. Turning the camera on during a voice call works as it does in a one-to-one call, and call events from each participant stay with the group, so the call window keeps the group's name from the first ring to the end. + +- Video calls: WinZapp now finds the camera with the FFmpeg build it ships, which lists DirectShow devices without the "DirectShow video devices" heading. That build returned no camera at all, so a video call went out receive-only, with "No camera found" in the log. + - WPPConnect Server 2.10.61 is now offered as an update without waiting for a new WinZapp version. It only updates dependencies, and WinZapp's changes to the server are not affected. - macOS updates explain whether verification failed because of a connection, certificate, server or GitHub request-limit problem, and whether the verification file or the official release tag could not be checked. Temporary connection and server failures get one retry; request limits show a wait time and do not trigger repeated requests during that wait. diff --git a/client/changelog_es-ES.txt b/client/changelog_es-ES.txt index eb9cdb5bb..7ee0debe2 100644 --- a/client/changelog_es-ES.txt +++ b/client/changelog_es-ES.txt @@ -4,6 +4,10 @@ Esta versión trae las comunidades y una ventana de configuración reorganizada, NOVEDADES +- Llamadas grupales: los botones de llamada de voz y de vídeo aparecen ahora en la conversación del grupo e inician una llamada al grupo, y una llamada grupal entrante suena, se anuncia con el nombre del grupo y se puede contestar, con el mismo botón de contestar y el mismo atajo global de contestar que una llamada individual. Activar la cámara durante una llamada de voz funciona como en una llamada individual, y los eventos de llamada de cada participante quedan con el grupo, así que la ventana de la llamada mantiene el nombre del grupo desde el primer tono hasta el final. + +- Videollamadas: WinZapp ahora encuentra la cámara con el FFmpeg incluido en WinZapp, que enumera los dispositivos DirectShow sin el encabezado "DirectShow video devices". Esa versión de FFmpeg no devolvía ninguna cámara, así que la videollamada salía solo recibiendo, con "No camera found" en el registro. + - WPPConnect Server 2.10.61 ya se ofrece como actualización sin esperar a una nueva versión de WinZapp. Solo actualiza dependencias y no afecta a los cambios que WinZapp hace en el servidor. - Las actualizaciones de macOS explican si la verificación ha fallado por un problema de conexión, certificado, servidor o límite de solicitudes de GitHub, y si no se ha podido comprobar el archivo de verificación o la etiqueta de la versión oficial. Los fallos temporales de conexión y servidor se reintentan una vez; los límites de solicitudes muestran un tiempo de espera y no generan nuevas solicitudes durante ese período. diff --git a/client/changelog_pl.txt b/client/changelog_pl.txt index 19048de71..b2dd3ca0a 100644 --- a/client/changelog_pl.txt +++ b/client/changelog_pl.txt @@ -4,6 +4,10 @@ W tej wersji pojawiają się społeczności i zreorganizowane okno ustawień, op NOWOŚCI +- Połączenia grupowe: przyciski połączenia głosowego i wideo pojawiają się teraz w rozmowie grupowej i rozpoczynają połączenie z grupą, a przychodzące połączenie grupowe dzwoni, jest zapowiadane nazwą grupy i można je odebrać tym samym przyciskiem odbierania i tym samym globalnym skrótem odbierania co połączenie indywidualne. Włączenie kamery podczas połączenia głosowego działa jak w połączeniu indywidualnym, a zdarzenia połączenia od każdego uczestnika pozostają przy grupie, więc okno połączenia zachowuje nazwę grupy od pierwszego dzwonka do końca. + +- Połączenia wideo: WinZapp znajduje teraz kamerę za pomocą dołączonego FFmpeg, który wyświetla urządzenia DirectShow bez nagłówka „DirectShow video devices”. Ta wersja FFmpeg nie zwracała żadnej kamery, więc połączenie wideo było tylko odbiorcze, z „No camera found” w logu. + - WPPConnect Server 2.10.61 jest teraz oferowany jako aktualizacja bez czekania na nową wersję WinZapp. Aktualizuje tylko zależności i nie wpływa na zmiany, które WinZapp wprowadza w serwerze. - Aktualizacje macOS wyjaśniają, czy weryfikacja nie powiodła się z powodu połączenia, certyfikatu, serwera lub limitu żądań GitHub oraz czy nie można sprawdzić pliku weryfikacji czy oficjalnego tagu wydania. Tymczasowe błędy połączenia i serwera powodują jedną ponowną próbę; limity żądań pokazują czas oczekiwania i nie powodują kolejnych żądań w tym czasie. diff --git a/client/changelog_pt-BR.txt b/client/changelog_pt-BR.txt index dd762ce5e..6975c4bc6 100644 --- a/client/changelog_pt-BR.txt +++ b/client/changelog_pt-BR.txt @@ -4,6 +4,10 @@ Esta versão traz as comunidades e uma janela de configurações reorganizada, a NOVIDADES +- Chamadas em grupo: os botões de chamada de voz e de vídeo agora aparecem na conversa do grupo e iniciam uma chamada para o grupo, e uma chamada em grupo recebida toca, é anunciada pelo nome do grupo e pode ser atendida, com o mesmo botão de atender e o mesmo atalho global de atender de uma chamada individual. Ligar a câmera durante uma chamada de voz funciona como numa chamada individual, e os eventos de chamada de cada participante ficam com o grupo, então a janela da chamada mantém o nome do grupo do primeiro toque até o fim. + +- Chamadas de vídeo: o WinZapp agora encontra a câmera com o FFmpeg incluído no WinZapp, que lista os dispositivos DirectShow sem o cabeçalho "DirectShow video devices". Essa versão do FFmpeg não retornava câmera nenhuma, então a chamada de vídeo saía só recebendo, com "No camera found" no log. + - O WPPConnect Server 2.10.61 agora é oferecido como atualização sem esperar por uma nova versão do WinZapp. Ele só atualiza dependências e não afeta as alterações que o WinZapp faz no servidor. - As atualizações do macOS explicam se a verificação falhou por um problema de conexão, certificado, servidor ou limite de solicitações do GitHub, e se não foi possível verificar o arquivo de verificação ou a tag da versão oficial. Falhas temporárias de conexão e servidor são repetidas uma vez; limites de solicitações mostram um tempo de espera e não geram novas solicitações durante esse período. diff --git a/client/changelog_pt-PT.txt b/client/changelog_pt-PT.txt index 858156b24..024d0319d 100644 --- a/client/changelog_pt-PT.txt +++ b/client/changelog_pt-PT.txt @@ -4,6 +4,10 @@ Esta versão traz as comunidades e uma janela de definições reorganizada, alé NOVIDADES +- Chamadas de grupo: os botões de chamada de voz e de vídeo aparecem agora na conversa do grupo e iniciam uma chamada para o grupo, e uma chamada de grupo recebida toca, é anunciada pelo nome do grupo e pode ser atendida, com o mesmo botão de atender e o mesmo atalho global de atender de uma chamada individual. Ligar a câmara durante uma chamada de voz funciona como numa chamada individual, e os eventos de chamada de cada participante ficam com o grupo, pelo que a janela da chamada mantém o nome do grupo desde o primeiro toque até ao fim. + +- Chamadas de vídeo: o WinZapp encontra agora a câmara com o FFmpeg incluído no WinZapp, que lista os dispositivos DirectShow sem o cabeçalho "DirectShow video devices". Essa versão do FFmpeg não devolvia câmara nenhuma, pelo que a chamada de vídeo seguia apenas a receber, com "No camera found" no registo. + - O WPPConnect Server 2.10.61 passa a ser oferecido como atualização sem esperar por uma nova versão do WinZapp. Só atualiza dependências e não afeta as alterações que o WinZapp faz no servidor. - As atualizações do macOS explicam se a verificação falhou devido a um problema de ligação, certificado, servidor ou limite de pedidos do GitHub, e se não foi possível verificar o ficheiro de verificação ou a etiqueta da versão oficial. As falhas temporárias de ligação e servidor são repetidas uma vez; os limites de pedidos mostram um tempo de espera e não geram novos pedidos durante esse período. diff --git a/client/changelog_ro.txt b/client/changelog_ro.txt index 8fe2c9b08..365d816d7 100644 --- a/client/changelog_ro.txt +++ b/client/changelog_ro.txt @@ -4,6 +4,10 @@ Această versiune aduce comunitățile și o fereastră de setări reorganizată NOUTĂȚI +- Apeluri de grup: butoanele de apel vocal și video apar acum în conversația de grup și pornesc un apel către grup, iar un apel de grup primit sună, este anunțat cu numele grupului și poate fi răspuns, cu același buton de răspuns și aceeași comandă rapidă globală de răspuns ca un apel individual. Pornirea camerei în timpul unui apel vocal funcționează ca într-un apel individual, iar evenimentele de apel de la fiecare participant rămân la grup, așa că fereastra apelului păstrează numele grupului de la primul sunet până la final. + +- Apeluri video: WinZapp găsește acum camera cu FFmpeg-ul inclus în WinZapp, care listează dispozitivele DirectShow fără antetul „DirectShow video devices”. Acea versiune de FFmpeg nu returna nicio cameră, așa că apelul video ieșea doar cu recepție, cu „No camera found” în log. + - WPPConnect Server 2.10.61 este acum oferit ca actualizare, fără a aștepta o nouă versiune WinZapp. Actualizează doar dependențe și nu afectează modificările pe care WinZapp le face în server. - Actualizările macOS explică dacă verificarea a eșuat din cauza conexiunii, certificatului, serverului sau limitei de solicitări GitHub și dacă nu a putut fi verificat fișierul de verificare sau eticheta versiunii oficiale. Erorile temporare de conexiune și server sunt reîncercate o singură dată; limitele de solicitări afișează un timp de așteptare și nu generează solicitări repetate în acel interval. diff --git a/client/changelog_tr-TR.txt b/client/changelog_tr-TR.txt index fb81ea830..dbf36b46a 100644 --- a/client/changelog_tr-TR.txt +++ b/client/changelog_tr-TR.txt @@ -4,6 +4,10 @@ Bu sürüm, 2.1.0.0 sürümündeki her şeye ek olarak toplulukları ve yeniden YENİLİKLER +- Grup aramaları: sesli ve görüntülü arama düğmeleri artık grup sohbetinde görünür ve gruba arama başlatır; gelen bir grup araması çalar, grubun adıyla duyurulur ve tek kişilik aramadaki aynı Yanıtla düğmesi ve genel yanıtlama kısayoluyla yanıtlanabilir. Sesli arama sırasında kamerayı açmak tek kişilik aramadaki gibi çalışır ve her katılımcıdan gelen arama olayları grupta kalır, böylece arama penceresi ilk çalmadan sona kadar grubun adını korur. + +- Görüntülü aramalar: WinZapp artık kamerayı, DirectShow aygıtlarını "DirectShow video devices" başlığı olmadan listeleyen ve WinZapp ile birlikte gelen FFmpeg ile bulur. Bu FFmpeg sürümü hiçbir kamera döndürmüyordu, bu yüzden görüntülü arama yalnızca alım modunda başlıyordu ve günlükte "No camera found" yazıyordu. + - WPPConnect Server 2.10.61 artık yeni bir WinZapp sürümü beklenmeden güncelleme olarak sunuluyor. Yalnızca bağımlılıkları günceller ve WinZapp'ın sunucuda yaptığı değişiklikleri etkilemez. - macOS güncellemelerinde doğrulamanın bağlantı, sertifika, sunucu veya GitHub istek sınırı nedeniyle mi başarısız olduğu ve doğrulama dosyasının mı, resmî sürüm etiketinin mi kontrol edilemediği açıklanır. Geçici bağlantı ve sunucu hatalarında bir kez yeniden denenir; istek sınırında bekleme süresi gösterilir ve bu süre boyunca tekrar istek gönderilmez. diff --git a/client/core/call_logic.py b/client/core/call_logic.py index ca2d11dba..42cf6b0c9 100644 --- a/client/core/call_logic.py +++ b/client/core/call_logic.py @@ -2,12 +2,18 @@ def incoming_call_can_answer(details: dict | None) -> bool: - """Return whether this one-to-one incoming call can be answered.""" - return not bool((details or {}).get("is_group")) + """Return whether this incoming call can be answered. + + Group offers are answerable: WhatsApp Web accepts a group call through the + same native ``voipStack.acceptCall`` its own Answer button uses, and + WinZapp attaches Python audio to the resulting call. This stays the single + decision point for the alert UI and the global answer key. + """ + return True def active_call_label_key(active: dict | None) -> str: - """Return the label for an active individual call.""" + """Return the label for an active call, group calls included.""" return "video_call_active_label" if (active or {}).get("is_video") else "voice_call_active_label" @@ -79,8 +85,8 @@ def call_hotkey_setting(general, key): def first_answerable_call(active_calls, details_map) -> str: """Return the identity of the oldest ringing call that can be answered. - A group offer rings too, but cannot be answered, so it must not be the - call a global "answer" key acts on when a one-to-one call rings beside it. + Group offers ring and are answerable too, so they take part in the same + oldest-first order as one-to-one calls rather than being skipped. """ for identity in active_calls or (): if incoming_call_can_answer((details_map or {}).get(identity)): diff --git a/client/core/call_video.py b/client/core/call_video.py index 0e7c51d68..b3850146d 100644 --- a/client/core/call_video.py +++ b/client/core/call_video.py @@ -9,7 +9,21 @@ def camera_names(ffmpeg_output: str) -> list[str]: - """Extract DirectShow video device names, excluding audio and aliases.""" + """Extract DirectShow video device names, excluding audio and aliases. + + Two listing formats are in the field. Newer FFmpeg builds print a + ``DirectShow video devices`` header and list bare quoted names under it. + The build WinZapp bundles (``client/lib/ffmpeg.exe``, which + ``_find_api_ffmpeg()`` prefers) prints no header at all and tags each + line itself:: + + [dshow @ 000] "HP TrueVision HD Camera" (video) + [dshow @ 000] "Microphone Array (Realtek(R) Audio)" (audio) + + Requiring the header made that build enumerate zero cameras, so a real + camera was reported as "No camera found" and the call went out + receive-only. + """ names = [] in_video = False for line in ffmpeg_output.splitlines(): @@ -18,13 +32,24 @@ def camera_names(ffmpeg_output: str) -> list[str]: continue if 'DirectShow audio devices' in line: in_video = False - if not in_video or 'Alternative name' in line: + continue + if 'Alternative name' in line: continue # Recent FFmpeg builds may append a media-kind suffix, e.g. # [dshow @ ...] "Integrated Camera" (video) # and spacing after the dshow prefix is not stable. - match = re.search(r'\]\s+"([^"]+)"(?:\s+\(video\))?\s*$', line, re.IGNORECASE) - if match: + match = re.search( + r'\]\s+"([^"]+)"(?:\s+\((video|audio)\))?\s*$', line, re.IGNORECASE + ) + if not match: + continue + kind = (match.group(2) or '').lower() + if kind == 'audio': + continue + # A tagged (video) line names its own kind, so it is authoritative + # without the header. An untagged name still needs the header, which + # is what keeps audio devices in the older format out. + if kind == 'video' or in_video: names.append(match.group(1)) return names diff --git a/client/main_window/calls.py b/client/main_window/calls.py index 65146f959..ece0db327 100644 --- a/client/main_window/calls.py +++ b/client/main_window/calls.py @@ -78,10 +78,8 @@ def _expire_incoming_call_alert(self, identity: str): if not self._active_incoming_calls: if hasattr(self, "call_incoming_sound"): self.call_incoming_sound.stop() - # Keyed to an ANSWERABLE call, not to "any alert is still up" -- see - # stop_incoming_call_alert(). Group offers sit in the dictionary too - # and never open a monitor, so the old emptiness test held the speaker - # for as long as one kept ringing beside the expired one-to-one call. + # The monitor is shared by every ringing call and is released only + # when no answerable offer remains -- see stop_incoming_call_alert(). if not self._has_answerable_incoming_call(): self._stop_incoming_call_audio_monitor() self._sync_incoming_call_bar() @@ -235,18 +233,15 @@ def stop_incoming_call_alert( if not self._active_incoming_calls: if hasattr(self, "call_incoming_sound"): self.call_incoming_sound.stop() - # The receive-only monitor is keyed to a call that can actually be - # ANSWERED, not to "any alert is still up". Group offers now sit in - # _active_incoming_calls too (they are announced, just not answerable), - # and they never start a monitor -- so gating this on the dictionary - # being empty left the speaker held open after the one-to-one call was - # dismissed, for as long as a group offer kept ringing beside it. + # The receive-only monitor is shared by every ringing call, so it is + # released only when no answerable offer remains, not merely because + # one of them was dismissed. if not keep_audio_monitor and not self._has_answerable_incoming_call(): self._stop_incoming_call_audio_monitor() self._sync_incoming_call_bar() def _has_answerable_incoming_call(self) -> bool: - """Whether any still-ringing offer could be accepted (i.e. not a group).""" + """Whether any still-ringing offer could be accepted.""" details_map = getattr(self, "_incoming_call_details", {}) return any( incoming_call_can_answer(details_map.get(identity)) @@ -447,10 +442,17 @@ def _start_voice_call_audio(self, identity: str, details: dict | None = None, wx.CallAfter(self._sync_voice_call_bar) return True details = details or getattr(self, "_incoming_call_details", {}).get(identity, {}) + is_group = bool(details.get("is_group", False)) + group_jid = str(details.get("group_jid") or "") self._active_voice_call = { "identity": identity, "call_id": details.get("call_id") or identity, - "peer_jid": details.get("peer_jid") or "", + # A group call's event names a participant in peerJid and the group + # in groupJid. The active record -- and every later state event -- + # is keyed on the group, never on whichever participant spoke. + "peer_jid": group_jid if is_group else (details.get("peer_jid") or ""), + "group_jid": group_jid, + "is_group": is_group, "name": details.get("name") or "", "outgoing": bool(details.get("outgoing", False)), "is_video": bool(details.get("is_video", False)), @@ -863,10 +865,14 @@ def accept_incoming_call(self, identity: str, *, with_video: bool | None = None) # Keep the receive-only monitor alive so accepting can promote the # same CallAudioSession to full duplex without reopening the speaker. self.stop_incoming_call_alert(identity, keep_audio_monitor=True) + is_group = bool(details.get("is_group", False)) + group_jid = str(details.get("group_jid") or "") self._active_voice_call = { "identity": identity, "call_id": details.get("call_id") or identity, - "peer_jid": details.get("peer_jid") or "", + "peer_jid": group_jid if is_group else (details.get("peer_jid") or ""), + "group_jid": group_jid, + "is_group": is_group, "name": details.get("name") or "", "outgoing": False, "is_video": is_video, @@ -1307,13 +1313,33 @@ def _on_voice_call_window_close(self, event): event.Skip() def start_voice_call(self, peer_jid: str, name: str = ""): - self._start_individual_call(peer_jid, name, is_video=False) + if str(peer_jid or "").endswith("@g.us"): + self._start_group_call(peer_jid, name, is_video=False) + else: + self._start_individual_call(peer_jid, name, is_video=False) def start_video_call(self, peer_jid: str, name: str = ""): - self._start_individual_call(peer_jid, name, is_video=True) + if str(peer_jid or "").endswith("@g.us"): + self._start_group_call(peer_jid, name, is_video=True) + else: + self._start_individual_call(peer_jid, name, is_video=True) def _start_individual_call(self, peer_jid: str, name: str, *, is_video: bool): """Start a one-to-one WhatsApp voice/video call using Python-owned media.""" + self._start_call(peer_jid, name, is_video=is_video, is_group=False) + + def _start_group_call(self, peer_jid: str, name: str, *, is_video: bool): + """Start a WhatsApp group voice/video call using Python-owned media. + + The page's own group-info buttons call + ``startWAWebVoipGroupCallFromChat``; the Node side owns that and this + record only has to follow the group, never one of its participants. + """ + self._start_call(peer_jid, name, is_video=is_video, is_group=True) + + def _start_call(self, peer_jid: str, name: str, *, is_video: bool, + is_group: bool): + """Place an outgoing call and keep Python's audio attached to it.""" peer_jid = self._normalize_jid(str(peer_jid or "")) # The self-chat belongs on this list with the other unreachable kinds. # Hiding the call buttons for it (#268) covers the mouse and the Tab @@ -1322,11 +1348,16 @@ def _start_individual_call(self, peer_jid: str, name: str, *, is_video: bool): # "calling Me..." and POSTed an offer to the user's own JID -- with the # button hidden, a blind user had no way to know the action even # existed there. This is the layer every path goes through. - if ( + unreachable = ( not peer_jid - or peer_jid.endswith(("@g.us", "@newsletter", "@broadcast")) + or peer_jid.endswith(("@newsletter", "@broadcast")) or self._is_self_jid(peer_jid) - ): + ) + if is_group: + unreachable = unreachable or not peer_jid.endswith("@g.us") + else: + unreachable = unreachable or peer_jid.endswith("@g.us") + if unreachable: self.output(self.i18n.t("voice_call_individual_only"), interrupt=True) return if ( @@ -1339,7 +1370,13 @@ def _start_individual_call(self, peer_jid: str, name: str, *, is_video: bool): details = { "call_id": identity, "peer_jid": peer_jid, - "name": name or self._preview_sender_from_jid(peer_jid) or peer_jid, + "group_jid": peer_jid if is_group else "", + "is_group": is_group, + "name": name or ( + self._group_display_name(peer_jid) + if is_group + else self._preview_sender_from_jid(peer_jid) or peer_jid + ), "outgoing": True, "is_video": is_video, } @@ -1372,7 +1409,10 @@ def _worker(): # Taken after the audio start, which is what installs the # record this attempt owns. call_record = getattr(self, "_active_voice_call", None) - dial_jid = self._resolve_jid_for_send(peer_jid) or peer_jid + dial_jid = ( + peer_jid if is_group + else self._resolve_jid_for_send(peer_jid) or peer_jid + ) # The offer POST runs OUTSIDE _call_action_lock, for the same # reason as the camera below: it blocks for up to 75 seconds @@ -1382,8 +1422,12 @@ def _worker(): # still in flight -- that is what ``cancelled`` settles. response = self._raise_for_call_response( self._post_call_control( - "offer", - {"to": dial_jid, "isVideo": is_video}, + "group/offer" if is_group else "offer", + ( + {"groupJid": dial_jid, "isVideo": is_video} + if is_group + else {"to": dial_jid, "isVideo": is_video} + ), timeout=75, ), "offer", @@ -1607,32 +1651,32 @@ def on_voice_call_state_event(self, event: dict): peer_jid = self._normalize_jid(str(event.get("peerJid") or "")) # A real group JID, for the same reason as on_incoming_call_event(): # the Node side infers isGroup from participant count, so the flag can - # be asserted for a one-to-one call. Trusting it alone here meant a - # call answered on the phone or from the page controls arrived as - # ACTIVE, was discarded as a group event, and WinZapp never adopted - # it -- no "call connected", no Python audio attached, nothing on - # screen. The user was in a call their PC pretended not to see. - is_group_event = bool( - peer_jid.endswith("@g.us") - or str(event.get("groupJid") or "").endswith("@g.us") - ) + # be asserted for a one-to-one call. A group event also names a + # participant in peerJid and the group in groupJid, so the active call + # is keyed on the group -- never on whichever participant is speaking. + group_jid = self._normalize_jid(str(event.get("groupJid") or "")) + if not group_jid and peer_jid.endswith("@g.us"): + group_jid = peer_jid + is_group_event = group_jid.endswith("@g.us") + call_peer_jid = group_jid if is_group_event else peer_jid active = getattr(self, "_active_voice_call", None) if not active: - # A group call is never adopted as the active call. This guard - # deliberately sits INSIDE the "no active call" branch: applied - # before the match below, a single false-positive isGroup (the Node - # side now also infers it from participant count) would swallow the - # terminal ENDED of a real one-to-one call, leaving the microphone - # open and the call window on screen after the other side hung up. - if is_group_event: - return + # A call answered on the phone or from the page controls is adopted + # here, group calls included: otherwise no "call connected" is + # spoken, no Python audio is attached and nothing appears on screen + # while the user is in a call their PC pretends not to see. if state != "ACTIVE" or not call_id: return active = { "identity": call_id, "call_id": call_id, - "peer_jid": peer_jid, - "name": self._preview_sender_from_jid(peer_jid) or peer_jid, + "peer_jid": call_peer_jid, + "group_jid": group_jid, + "is_group": is_group_event, + "name": ( + self._group_display_name(group_jid) if is_group_event + else self._preview_sender_from_jid(call_peer_jid) or call_peer_jid + ), "outgoing": bool(event.get("outgoing", False)), "is_video": bool(event.get("isVideo")), } @@ -1640,7 +1684,7 @@ def on_voice_call_state_event(self, event: dict): self._voice_call_last_announced_state = "" wx.CallAfter(self._sync_voice_call_bar) if not call_event_matches_active( - active, call_id, peer_jid, self._chat_jids_equivalent + active, call_id, call_peer_jid, self._chat_jids_equivalent ): return if call_id: @@ -1652,10 +1696,16 @@ def on_voice_call_state_event(self, event: dict): upgraded_to_video = bool(event.get("isVideo")) and not active.get("is_video") if event.get("isVideo"): active["is_video"] = True - if peer_jid: - active["peer_jid"] = peer_jid - if not active.get("name") and peer_jid: - active["name"] = self._preview_sender_from_jid(peer_jid) or peer_jid + if is_group_event: + active["group_jid"] = group_jid + active["is_group"] = True + if call_peer_jid: + active["peer_jid"] = call_peer_jid + if not active.get("name") and call_peer_jid: + active["name"] = ( + self._group_display_name(group_jid) if is_group_event + else self._preview_sender_from_jid(call_peer_jid) or call_peer_jid + ) self._sync_voice_call_bar() terminal_states = { @@ -1664,7 +1714,7 @@ def on_voice_call_state_event(self, event: dict): } if state in terminal_states or event.get("event") in {"ended", "timeout"}: self._confirm_call_ended( - active, call_id, peer_jid, upgraded_to_video=upgraded_to_video) + active, call_id, call_peer_jid, upgraded_to_video=upgraded_to_video) return if upgraded_to_video: self._on_call_upgraded_to_video() @@ -1926,6 +1976,14 @@ def _fetch_call_log_record(self, candidates: list): return data return None + def _group_display_name(self, group_jid: str) -> str: + """The group's name from the loaded chat, cache, or a spoken fallback.""" + chat = getattr(self, "chats", {}).get(group_jid, {}) if group_jid else {} + name = self._group_name_from_chat_dict(chat) if chat else "" + if not name and group_jid: + name = getattr(self, "_group_name_cache", {}).get(group_jid, "") + return name or self.i18n.t("unknown_group") + def on_incoming_call_event(self, event: dict): """Announce an incoming call and keep its tone playing until it ends. @@ -1971,12 +2029,20 @@ def on_incoming_call_event(self, event: dict): ) return + # A group call names a participant in peerJid and the group in + # groupJid; a group event can also carry the group in peerJid itself. + group_jid = self._normalize_jid(str(event.get("groupJid") or "")) + if not group_jid and peer_jid.endswith("@g.us"): + group_jid = peer_jid + # State changes away from INCOMING_RING mean the call was answered on # another device, rejected, missed, failed, or otherwise ended. if not is_ringing: # The record WhatsApp writes for it is what shows the call in the - # conversation ("Ligação de voz perdida"). - self._watch_ended_call_log(call_id, peer_jid, False) + # conversation ("Ligação de voz perdida"). A group call's record + # belongs to the group chat, never to the participant that rang; + # _watch_ended_call_log() deliberately skips @g.us records. + self._watch_ended_call_log(call_id, group_jid or peer_jid, False) if call_id: self._active_incoming_calls.pop(call_id, None) getattr(self, "_incoming_call_details", {}).pop(call_id, None) @@ -2025,25 +2091,14 @@ def on_incoming_call_event(self, event: dict): identity = call_id or peer_jid if not identity or identity in self._active_incoming_calls: return - # WPPConnect cannot answer a group call, so `is_group` keeps the Answer - # button disabled (incoming_call_can_answer()). It must NOT silence the - # offer: the page's own ringtone is muted by callMediaBridge, so - # dropping the event here left a blind user with no signal at all that - # their phone was ringing -- and no log line to explain it afterwards. - # The alert follows the same two Calls settings as a one-to-one offer. - group_jid = self._normalize_jid(str(event.get("groupJid") or "")) # A real group JID is required, not merely the isGroup flag. The Node - # side now infers isGroup from participant count too + # side also infers isGroup from participant count # (groupParticipantCountOf(call) > 1 in createSessionUtil.ts), so the - # flag can be asserted for a one-to-one call -- which is exactly why - # on_voice_call_state_event's own group guard had to move inside its - # "no active call" branch. Trusting it here costs more, not less: a - # false positive would announce "incoming group call in Unnamed group" - # instead of the caller's name AND disable the Answer button via - # incoming_call_can_answer(), so the user simply could not answer a - # real call from a friend. CLAUDE.md's rule for the other direction - # applies here too: a @g.us is not trustworthy alone, and neither is - # a group claim with no @g.us behind it. + # flag can be asserted for a one-to-one call. Announcing a false one as + # a group would hide the caller's name and promise a group call that + # does not exist. CLAUDE.md's rule applies in both directions: a @g.us + # is not trustworthy alone, and neither is a group claim with no @g.us + # behind it. is_group = group_jid.endswith("@g.us") or peer_jid.endswith("@g.us") if event.get("isGroup") and not is_group: logging.info( @@ -2051,14 +2106,8 @@ def on_incoming_call_event(self, event: dict): "individual id=%s peer=%s", call_id, peer_jid, ) if is_group: - chat = getattr(self, "chats", {}).get(group_jid, {}) if group_jid else {} - group_name = self._group_name_from_chat_dict(chat) if chat else "" - if not group_name and group_jid: - group_name = getattr(self, "_group_name_cache", {}).get(group_jid, "") - if not group_name: - group_name = self.i18n.t("unknown_group") - caller_name = group_name - message = self.i18n.t("incoming_group_call_announcement").format(name=group_name) + caller_name = self._group_display_name(group_jid) + message = self.i18n.t("incoming_group_call_announcement").format(name=caller_name) else: # Keep the proven one-to-one call path unchanged: peerJid is the # caller and resolves through the existing contact-name machinery. @@ -2081,12 +2130,11 @@ def on_incoming_call_event(self, event: dict): } self._arm_incoming_call_watchdog(identity) self._incoming_call_details[identity]["message"] = message - # Only for a call that CAN be answered: the monitor exists so accepting - # can promote the same session to full duplex without reopening the - # speaker, and a group offer never gets that far. Opening an output - # stream for it would hold the device for nothing. - if not is_group: - self._start_incoming_call_audio_monitor(identity) + # Every answerable call gets the receive-only monitor, group calls + # included: accepting promotes this same session to full duplex without + # reopening the speaker. WhatsApp's own ringtone is muted by the page + # bridge, so this is also how the caller is heard while it rings. + self._start_incoming_call_audio_monitor(identity) self.output(message, interrupt=True) if hasattr(self, "call_incoming_sound"): self.call_incoming_sound.play() diff --git a/client/ui/conversation_panel/composer.py b/client/ui/conversation_panel/composer.py index b8e5d3100..0e082682f 100644 --- a/client/ui/conversation_panel/composer.py +++ b/client/ui/conversation_panel/composer.py @@ -405,7 +405,10 @@ def _should_redirect_char_to_message(self, event) -> bool: def _sync_voice_call_button(self, jid: str): jid = str(jid or "") is_self_chat = bool(jid) and self.main_window._is_self_jid(jid) - unavailable = jid.endswith(("@g.us", "@newsletter", "@broadcast")) or is_self_chat + # Groups are callable too: WhatsApp Web's group-info buttons start the + # same native group call WinZapp asks the Node patch for. Newsletters, + # broadcast lists and the self-chat have no call to place. + unavailable = jid.endswith(("@newsletter", "@broadcast")) or is_self_chat self._voice_call_btn.Show(bool(jid) and not unavailable) self._video_call_btn.Show(bool(jid) and not unavailable) self.conversation_panel.Layout() diff --git a/tests/test_call_control_api_patch.py b/tests/test_call_control_api_patch.py index 9beb17c98..0e0f9e85d 100644 --- a/tests/test_call_control_api_patch.py +++ b/tests/test_call_control_api_patch.py @@ -1,5 +1,7 @@ import re from pathlib import Path +from types import SimpleNamespace + from tests.god_modules import main_window_source ROOT = Path(__file__).resolve().parents[1] @@ -17,6 +19,7 @@ def test_wppconnect_patch_exposes_voice_call_control_routes(): assert '/api/:session/call/reject' in routes assert '/api/:session/call/end' in routes assert '/api/:session/call/offer' in routes + assert '/api/:session/call/group/offer' in routes assert '/api/:session/call/diagnostics' in routes assert "getVoipStackInterface" in controller assert "enableCallInterface" in controller @@ -29,6 +32,9 @@ def test_wppconnect_patch_exposes_voice_call_control_routes(): assert "WPP.call.rejectCall" in controller assert "WPP.call.end" in controller assert "WPP.call.offer" in controller + assert "startWAWebVoipGroupCallFromChat" in controller + assert "offerGroupCall" in controller + assert "group-offer" in controller assert "prepareAudioBridge(req)" in controller assert "isOutgoingOrLiveCall" in controller assert "without successful voipInit" in controller @@ -375,12 +381,59 @@ def test_setup_api_copies_call_patch_files_into_runtime_api(): def test_outgoing_call_allows_native_voip_more_than_generic_http_timeout(): main_py = main_window_source() - offer = main_py[main_py.index("def _start_individual_call"):] + offer = main_py[main_py.index("def _start_call(self"):] offer = offer[: offer.index("threading.Thread(target=_worker")] - assert '"offer",' in offer + assert '"group/offer" if is_group else "offer",' in offer assert "timeout=75," in offer - assert "dial_jid = self._resolve_jid_for_send(peer_jid) or peer_jid" in offer + assert "self._resolve_jid_for_send(peer_jid) or peer_jid" in offer assert '{"to": dial_jid, "isVideo": is_video}' in offer + assert '{"groupJid": dial_jid, "isVideo": is_video}' in offer + + +def test_call_control_urls_prefix_call_exactly_once(monkeypatch): + """Every control URL is ``/api//call/``. + + The group offer used to pass ``"call/group/offer"`` as the endpoint, and + the prefix turned that into ``/call/call/group/offer`` — a 404 that no + test caught, because the call-path tests all stub ``_post_call_control`` + itself and never see the URL it builds. + """ + from main_window import calls as calls_module + + urls = [] + + def _capture(url, **_kwargs): + urls.append(url) + return object() + + monkeypatch.setattr(calls_module, "api_post", _capture) + stub = SimpleNamespace( + wpp_server="http://127.0.0.1", wpp_port=6300, token="tok" + ) + calls_module.CallsMixin._post_call_control( + stub, "group/offer", {"groupJid": "1@g.us"} + ) + calls_module.CallsMixin._post_call_control( + stub, "offer", {"to": "1@s.whatsapp.net"} + ) + + assert urls == [ + "http://127.0.0.1:6300/api/tok/call/group/offer", + "http://127.0.0.1:6300/api/tok/call/offer", + ] + + +def test_group_calls_are_dispatched_and_started_without_a_peer_dial(): + main_py = main_window_source() + + assert 'if str(peer_jid or "").endswith("@g.us"):' in main_py + assert "self._start_group_call(peer_jid, name, is_video=False)" in main_py + assert "self._start_group_call(peer_jid, name, is_video=True)" in main_py + group = main_py[main_py.index("def _start_call(self"):] + group = group[: group.index("threading.Thread(target=_worker")] + # A group has no contact JID to resolve, and its record follows the group. + assert "peer_jid if is_group" in group + assert '"group_jid": peer_jid if is_group else "",' in group def test_outgoing_call_prefers_new_active_call_over_stale_collection_model(): controller = _source("client/api_patches/src/controller/callController.ts") @@ -408,10 +461,38 @@ def test_offer_starts_the_call_as_a_user_gesture(): assert "await start(peer, isVideo, 8, 5, undefined, { entryTrust: 'user_gesture' })" in offer assert "return win.WPP.call.offer(to, { isVideo })" in offer assert "peer?.isUser?.()" in offer - assert "await startOutgoingCall(String(payload.to), !!payload.isVideo)" in offer + assert "() => startOutgoingCall(String(payload.to), !!payload.isVideo)" in offer assert "await win.WPP.call.offer(payload.to" not in offer +def test_group_offer_uses_whatsapps_own_group_start_entry_points(): + """A group call has no single peer to dial. The patch must go through + startWAWebVoipGroupCallFromChat with the GROUP_CHAT_INFO WAM entry points, + exactly like WhatsApp's own group-info buttons, and never hand-roll group + signaling or fall back to WPP.call.offer() for a group.""" + controller = _source("client/api_patches/src/controller/callController.ts") + + group = controller[controller.index("const startOutgoingGroupCall"):] + group = group[: group.index("const waitForOfferedCall")] + + assert "win.WPP?.whatsapp?.functions?.startWAWebVoipGroupCallFromChat" in group + assert "requireWa('WAWebVoipStartCall')?.startWAWebVoipGroupCallFromChat" in group + assert "requireWa('WAWebWidFactory')?.createWid" in group + assert "requireWa('WAWebChatCollection')?.ChatCollection" in group + assert "chats.get(createWid(groupJid))" in group + assert "chat.groupMetadata" in group + assert "WAWebWamEnumCallFromUi" in group + assert "WAWebWamEnumLobbyEntryPointType" in group + assert "await start(chat, isVideo, callFromUi, lobbyEntryPoint)" in group + assert "WPP.call.offer" not in group + + assert "if (action === 'group-offer')" in controller + assert "String(payload.groupJid)" in controller + assert "'an outgoing group'" in controller + assert "offerGroupCall" in controller + assert "Parameter groupJid is required!" in controller + + def test_active_call_poll_tolerates_transient_active_call_gaps(): diff --git a/tests/test_call_hotkeys.py b/tests/test_call_hotkeys.py index 79cebcddc..2eb7f1c81 100644 --- a/tests/test_call_hotkeys.py +++ b/tests/test_call_hotkeys.py @@ -166,12 +166,12 @@ def test_the_oldest_answerable_call_is_chosen(self): details = {"a": {}, "b": {}} assert first_answerable_call({"a": "x", "b": "y"}, details) == "a" - def test_a_group_offer_ringing_first_is_skipped(self): + def test_a_group_offer_is_chosen_in_ring_order(self): details = {"g": {"is_group": True}, "a": {}} - assert first_answerable_call({"g": "x", "a": "y"}, details) == "a" + assert first_answerable_call({"g": "x", "a": "y"}, details) == "g" - def test_only_a_group_offer_leaves_nothing_to_answer(self): - assert first_answerable_call({"g": "x"}, {"g": {"is_group": True}}) == "" + def test_only_a_group_offer_is_still_answerable(self): + assert first_answerable_call({"g": "x"}, {"g": {"is_group": True}}) == "g" def test_no_call_leaves_nothing_to_answer(self): assert first_answerable_call({}, {}) == "" @@ -294,12 +294,12 @@ def test_the_keys_are_released_when_the_call_is_over(self, recorder): assert all(m.stopped for m in recorder.created) assert _held(window) == {} - def test_a_group_offer_alone_takes_only_the_end_key(self, recorder): - """It cannot be answered, but it can be rejected.""" + def test_a_group_offer_alone_takes_both_call_keys(self, recorder): + """A group call can be answered and rejected like a one-to-one one.""" window = _Window() window.ring("g", is_group=True) window._sync_call_hotkeys() - assert _held(window) == {"end": CALL_END_HOTKEY} + assert _held(window) == {"answer": CALL_ANSWER_HOTKEY, "end": CALL_END_HOTKEY} def test_changed_settings_move_the_key_of_a_call_that_rings(self, recorder): window = _Window() @@ -382,12 +382,12 @@ def test_a_hidden_call_window_is_left_alone(self): window._focus_call_window() assert self.fronted == [] - def test_answer_skips_a_group_offer(self): + def test_answer_takes_the_oldest_group_offer(self): window = _Window() window.ring("g", is_group=True) window.ring("a") window._answer_call_from_hotkey() - assert window.accepted == [("a", None)] + assert window.accepted == [("g", None)] def test_answer_after_the_call_ended_does_nothing(self): window = _Window() diff --git a/tests/test_call_video.py b/tests/test_call_video.py index 2fd0e1fcc..aa0aafd86 100644 --- a/tests/test_call_video.py +++ b/tests/test_call_video.py @@ -34,6 +34,21 @@ def test_camera_names_accepts_current_ffmpeg_directshow_suffix_and_spacing(): assert camera_names(listing) == ["Integrated Camera", "USB Camera"] +def test_camera_names_reads_the_bundled_ffmpeg_listing_without_section_headers(): + """client/lib/ffmpeg.exe omits the "DirectShow video devices" header. + + That binary is what _find_api_ffmpeg() prefers, and it tags every device + line with (video)/(audio) instead. Requiring the header made it enumerate + zero cameras, so a real HP webcam was reported as "No camera found". + """ + listing = '''[dshow @ 000] "HP TrueVision HD Camera" (video) +[dshow @ 000] Alternative name "@device_pnp_abc" +[dshow @ 000] "Microphone Array (Realtek(R) Audio)" (audio) +[dshow @ 000] Alternative name "@device_cm_abc" +''' + assert camera_names(listing) == ["HP TrueVision HD Camera"] + + class _FakeCompletedProcess: def __init__(self, stderr): self.stderr = stderr @@ -226,9 +241,10 @@ def test_jpeg_pipe_discards_noise_and_yields_complete_frames(): ] -def test_individual_video_is_answerable_and_has_video_label(): +def test_individual_video_and_group_calls_are_answerable_and_have_labels(): assert incoming_call_can_answer({"is_video": True}) - assert not incoming_call_can_answer({"is_group": True}) + # A group offer is answerable through the same native accept path. + assert incoming_call_can_answer({"is_group": True}) assert active_call_label_key({"is_video": True}) == "video_call_active_label" @@ -240,15 +256,48 @@ def test_video_button_and_labels_exist_in_all_languages(): assert entries['voice_call_video_off_button'] assert entries['voice_call_video_on_button'] assert '{name}' in entries['incoming_video_call_announcement'] + # Group calls reuse the existing call labels; only the announcement has + # its own group wording. + assert '{name}' in entries['incoming_group_call_announcement'] + assert '{name}' in entries['voice_call_starting'] -def test_video_button_is_restricted_to_individual_chats(): +def test_video_button_is_offered_for_groups_but_not_unreachable_chats(): source = conversations_source() - assert 'unavailable = jid.endswith(("@g.us", "@newsletter", "@broadcast"))' in source + assert 'unavailable = jid.endswith(("@newsletter", "@broadcast")) or is_self_chat' in source + assert 'unavailable = jid.endswith(("@g.us", "@newsletter", "@broadcast"))' not in source assert 'self._video_call_btn.Show(bool(jid) and not unavailable)' in source assert 'self.main_window.start_video_call(jid, name)' in source +class _DispatchMainWindow: + start_voice_call = MainWindow.start_voice_call + start_video_call = MainWindow.start_video_call + + def __init__(self): + self.started = [] + + def _start_group_call(self, jid, name, *, is_video): + self.started.append(("group", jid, name, is_video)) + + def _start_individual_call(self, jid, name, *, is_video): + self.started.append(("individual", jid, name, is_video)) + + +def test_group_jids_use_the_group_call_path_and_contacts_do_not(): + stub = _DispatchMainWindow() + + stub.start_voice_call("120363427511142886@g.us", "Família") + stub.start_video_call("120363427511142886@g.us", "Família") + stub.start_voice_call("5511999999999@s.whatsapp.net", "Fulano") + + assert stub.started == [ + ("group", "120363427511142886@g.us", "Família", False), + ("group", "120363427511142886@g.us", "Família", True), + ("individual", "5511999999999@s.whatsapp.net", "Fulano", False), + ] + + class _NoCameraWs: def send_call_camera_frame(self, _frame, epoch=None): pass @@ -470,8 +519,8 @@ def test_camera_starts_after_the_offer_post_on_an_outgoing_video_call(): between pressing "video call" and the offer actually being placed.""" body = _body_between( _MAIN_SRC, - " def _start_individual_call(self", - " def _sync_voice_call_bar(self", + " def _start_call(self", + " def _abandon_outgoing_call(self", ) offer_post = body.index('"offer",') camera_start = body.index("self._start_call_camera()") @@ -590,6 +639,7 @@ def start(self): class _SelfChatMainWindow: _start_individual_call = MainWindow._start_individual_call + _start_call = MainWindow._start_call _normalize_jid = staticmethod(MainWindow._normalize_jid) def __init__(self): diff --git a/tests/test_incoming_call_alert.py b/tests/test_incoming_call_alert.py index 4e40f1598..c8312a499 100644 --- a/tests/test_incoming_call_alert.py +++ b/tests/test_incoming_call_alert.py @@ -94,6 +94,7 @@ class _MainStub: _CALL_EVENT_START_GRACE_SECONDS = MainWindow._CALL_EVENT_START_GRACE_SECONDS _normalize_jid = staticmethod(MainWindow._normalize_jid) _group_name_from_chat_dict = staticmethod(MainWindow._group_name_from_chat_dict) + _group_display_name = MainWindow._group_display_name on_incoming_call_event = MainWindow.on_incoming_call_event _expire_incoming_call_alert = MainWindow._expire_incoming_call_alert stop_incoming_call_alert = MainWindow.stop_incoming_call_alert @@ -322,14 +323,14 @@ def test_in_window_stop_button_clears_non_popup_call_surface(): assert stub.call_incoming_sound.stop_calls == 1 -def test_group_offer_is_announced_by_group_name_but_cannot_be_answered(): +def test_group_offer_is_announced_by_group_name_and_can_be_answered(): """REGRESSION: the group offer used to be dropped before the announcement, the ring tone and even the log line. WhatsApp Web's own ringtone is muted by callMediaBridge, so a blind user got NO signal at all that their phone was ringing, and log.log had nothing to explain it afterwards. - WPPConnect still cannot answer a group call, so the offer is announced and - shown like any other -- only `is_group` keeps the Answer button disabled. + A group offer is now answerable through the same native accept path as a + one-to-one call, so it is announced, shown and monitored like any other. """ stub = _MainStub() group_jid = "120363427511142886@g.us" @@ -348,12 +349,12 @@ def test_group_offer_is_announced_by_group_name_but_cannot_be_answered(): assert stub._active_incoming_calls != {} details = stub._incoming_call_details["call-1"] assert details["is_group"] is True + assert details["group_jid"] == group_jid assert details["name"] == "Família" - assert incoming_call_can_answer(details) is False - # The receive-only monitor exists so ANSWERING can promote the same session - # to full duplex. A group offer never gets that far, so opening an output - # stream for it would hold the device for nothing. - assert stub.ring_monitor_starts == [] + assert incoming_call_can_answer(details) is True + # The receive-only monitor is what makes the caller audible while it rings + # and lets accepting promote the same session to full duplex. + assert stub.ring_monitor_starts == ["call-1"] def test_group_offer_still_obeys_the_calls_alert_setting(): @@ -575,6 +576,73 @@ def test_stale_terminal_event_for_same_peer_does_not_end_current_call(monkeypatc assert stub._active_voice_call["call_id"] == "current-call" +def test_active_group_call_is_adopted_with_group_metadata(monkeypatch): + """A group call answered on the phone or from the page must become the + active call here too, keyed on the group -- never on the participant in + peerJid -- so the call window, audio and hang-up all follow it.""" + monkeypatch.setattr(wx, "CallAfter", lambda *_args: None) + stub = _MainStub() + stub._sync_voice_call_bar = lambda: None + stub._call_audio_session = object() # audio already attached + group_jid = "120363427511142886@g.us" + stub.chats[group_jid] = { + "remoteJid": group_jid, + "groupMetadata": {"subject": "Família"}, + } + + stub.on_voice_call_state_event({ + "event": "state", + "state": "ACTIVE", + "id": "group-call", + "peerJid": "5511888888888@lid", + "groupJid": group_jid, + "isGroup": True, + }) + + active = stub._active_voice_call + assert active["call_id"] == "group-call" + assert active["peer_jid"] == group_jid + assert active["group_jid"] == group_jid + assert active["is_group"] is True + assert active["name"] == "Família" + assert stub.announcements == [("Ligação conectada.", True)] + + +def test_group_state_event_matches_the_group_not_a_participant(monkeypatch): + """Once adopted, later events still carry a participant in peerJid. They + match on groupJid, so the call is not treated as a different call.""" + monkeypatch.setattr(wx, "CallAfter", lambda *_args: None) + stub = _MainStub() + stub._sync_voice_call_bar = lambda: None + stub._call_audio_session = object() + group_jid = "120363427511142886@g.us" + stub.chats[group_jid] = {"remoteJid": group_jid, "groupMetadata": {"subject": "Família"}} + stub._active_voice_call = { + "identity": "group-call", + "call_id": "group-call", + "peer_jid": group_jid, + "group_jid": group_jid, + "is_group": True, + "name": "Família", + "outgoing": False, + "is_video": False, + } + + stub.on_voice_call_state_event({ + "event": "state", + "state": "ACTIVE", + "id": "group-call", + "peerJid": "5511888888888@lid", + "groupJid": group_jid, + "isGroup": True, + }) + + assert stub._active_voice_call["peer_jid"] == group_jid + assert stub._active_voice_call["group_jid"] == group_jid + assert stub._active_voice_call["name"] == "Família" + assert stub._voice_call_last_announced_state == "ACTIVE" + + def test_websocket_forwards_remote_call_audio_to_main_window(): delivered = [] stub = SimpleNamespace( @@ -648,10 +716,9 @@ def test_isgroup_without_a_group_jid_is_treated_as_an_individual_call(): """REGRESSION: the Node side now infers isGroup from participant count (groupParticipantCountOf(call) > 1), so the flag can be asserted for a real one-to-one call. Trusting it alone announced "incoming group call in - Unnamed group" instead of the caller's name AND disabled the Answer button - via incoming_call_can_answer() -- the user simply could not answer a call - from a friend. CLAUDE.md's rule applies in both directions: a @g.us is not - trustworthy alone, and neither is a group claim with no @g.us behind it. + Unnamed group" instead of the caller's name. CLAUDE.md's rule applies in + both directions: a @g.us is not trustworthy alone, and neither is a group + claim with no @g.us behind it. """ stub = _MainStub() @@ -664,33 +731,28 @@ def test_isgroup_without_a_group_jid_is_treated_as_an_individual_call(): details = stub._incoming_call_details["call-1"] assert details["is_group"] is False assert incoming_call_can_answer(details) is True - # A real one-to-one call still gets its receive-only monitor. + # It is treated as a real one-to-one call, monitor included. assert stub.ring_monitor_starts == ["call-1"] -def test_dismissing_the_only_answerable_call_releases_the_speaker(): - """REGRESSION: the receive-only monitor is keyed to a call that can be - ANSWERED, but it was released only when _active_incoming_calls emptied. - Group offers now sit in that dictionary too -- announced, never answerable, - and they start no monitor -- so dismissing the one-to-one call left the - speaker held open for as long as a group offer kept ringing beside it.""" +def test_dismissing_a_group_offer_keeps_the_monitor_until_the_last_call(): + """The shared monitor is released only once no answerable offer remains. + A still-ringing group offer counts, because it can be answered too.""" stub = _MainStub() - group_jid = "120363427511142886@g.us" - stub.chats[group_jid] = {"remoteJid": group_jid, "groupMetadata": {"subject": "Família"}} - stub.on_incoming_call_event(_offer(call_id="one-to-one")) - group_event = _offer(call_id="group-call", peer="5511888888888@lid") - group_event.update({"isGroup": True, "groupJid": group_jid}) - stub.on_incoming_call_event(group_event) + _ringing_group_offer(stub) - assert stub.ring_monitor_starts == ["one-to-one"] + assert stub.ring_monitor_starts == ["one-to-one", "group-call"] assert stub.ring_monitor_stops == 0 stub.stop_incoming_call_alert("one-to-one") - # The group offer is still ringing, so the alert list is not empty -- but - # nothing answerable remains, so the speaker must be released. + # The group offer is still ringing and answerable, so the speaker stays. assert stub._active_incoming_calls == {"group-call": "5511888888888@lid"} + assert stub.ring_monitor_stops == 0 + + stub.stop_incoming_call_alert("group-call") + assert stub._active_incoming_calls == {} assert stub.ring_monitor_stops == 1 @@ -711,30 +773,32 @@ def _ringing_group_offer(stub, call_id="group-call"): event = _offer(call_id=call_id, peer="5511888888888@lid") event.update({"isGroup": True, "groupJid": group_jid}) stub.on_incoming_call_event(event) + return group_jid -def test_the_watchdog_timeout_also_releases_the_speaker(): - """REGRESSION: the answerable-call gate was applied only in - stop_incoming_call_alert(). The watchdog path kept the old emptiness test, - so a one-to-one call expiring beside a still-ringing group offer left the - speaker held -- and with exclusive_output on, that silences the screen - reader with nothing on screen to explain it.""" +def test_the_watchdog_timeout_keeps_the_speaker_for_a_ringing_group_offer(): + """The shared monitor is released only when no answerable offer remains, + so a group offer still ringing holds it exactly like a second one-to-one.""" stub = _MainStub() stub.on_incoming_call_event(_offer(call_id="one-to-one")) _ringing_group_offer(stub) - assert stub.ring_monitor_starts == ["one-to-one"] + assert stub.ring_monitor_starts == ["one-to-one", "group-call"] stub._expire_incoming_call_alert("one-to-one") assert "group-call" in stub._active_incoming_calls + assert stub.ring_monitor_stops == 0 + + stub._expire_incoming_call_alert("group-call") + assert stub._active_incoming_calls == {} assert stub.ring_monitor_stops == 1 -def test_a_terminal_call_event_also_releases_the_speaker(): - """Same gap in the non-ringing branch of on_incoming_call_event().""" +def test_a_terminal_call_event_keeps_the_speaker_for_a_ringing_group_offer(): + """Same shared-monitor rule in the non-ringing branch of the offer path.""" stub = _MainStub() stub.on_incoming_call_event(_offer(call_id="one-to-one")) - _ringing_group_offer(stub) + group_jid = _ringing_group_offer(stub) stub.on_incoming_call_event({ "event": "callstate", @@ -744,6 +808,17 @@ def test_a_terminal_call_event_also_releases_the_speaker(): }) assert "group-call" in stub._active_incoming_calls + assert stub.ring_monitor_stops == 0 + + stub.on_incoming_call_event({ + "event": "callstate", + "state": "ENDED", + "id": "group-call", + "peerJid": "5511888888888@lid", + "groupJid": group_jid, + }) + + assert stub._active_incoming_calls == {} assert stub.ring_monitor_stops == 1 diff --git a/tests/test_incoming_call_answer_without_video.py b/tests/test_incoming_call_answer_without_video.py index cc9bafff1..bde4a2225 100644 --- a/tests/test_incoming_call_answer_without_video.py +++ b/tests/test_incoming_call_answer_without_video.py @@ -207,6 +207,29 @@ def test_answer_without_video_on_a_voice_only_call_never_touches_the_camera(): assert stub._call_camera_available is None +def test_answering_a_group_call_records_the_group_not_the_ringing_participant(): + """The offer names a participant in peer_jid and the group in group_jid. + The accepted call -- and every later state event -- must follow the group.""" + stub = _MainStub() + group_jid = "120363427511142886@g.us" + stub._incoming_call_details["group-call"] = { + "call_id": "group-call", + "peer_jid": "5511888888888@lid", + "group_jid": group_jid, + "is_group": True, + "name": "Família", + "is_video": False, + } + stub._active_incoming_calls["group-call"] = "5511888888888@lid" + + stub.accept_incoming_call("group-call") + + assert stub._active_voice_call["peer_jid"] == group_jid + assert stub._active_voice_call["group_jid"] == group_jid + assert stub._active_voice_call["is_group"] is True + assert stub.posted_controls == [("accept", {"callId": "group-call"})] + + class _FakeParent: def __init__(self, i18n): self.i18n = i18n diff --git a/tests/test_outgoing_call_teardown.py b/tests/test_outgoing_call_teardown.py index 84b454c0e..d09bda4d8 100644 --- a/tests/test_outgoing_call_teardown.py +++ b/tests/test_outgoing_call_teardown.py @@ -2,7 +2,7 @@ Two defects, reported by an outside contributor and both real: -* ``_start_individual_call()``'s worker held ``_call_action_lock`` across the +* ``_start_call()``'s worker held ``_call_action_lock`` across the offer POST, whose timeout is 75 seconds. ``end_active_call()`` takes the same lock, so while an offer stalled the "end call" button did nothing at all. * Its ``except`` branch never cleared ``_active_voice_call``. The report says @@ -58,6 +58,7 @@ def json(self): class _MainStub: _start_individual_call = MainWindow._start_individual_call + _start_call = MainWindow._start_call _abandon_outgoing_call = MainWindow._abandon_outgoing_call end_active_call = MainWindow.end_active_call # Bound from the real class on purpose: a stub that invents a truthy diff --git a/tests/test_sync_voice_call_button.py b/tests/test_sync_voice_call_button.py index cab40508a..8dd453e5a 100644 --- a/tests/test_sync_voice_call_button.py +++ b/tests/test_sync_voice_call_button.py @@ -1,6 +1,7 @@ -"""Regression coverage for hiding the voice/video call buttons on chats -WhatsApp can't call: groups, newsletters, broadcast, and the user's own -self-chat ("Eu"), which has no one on the other end to call. +"""Regression coverage for the voice/video call buttons on chats WhatsApp +cannot call: newsletters, broadcast, and the user's own self-chat ("Eu"), +which has no one on the other end to call. Groups are callable, so they show +both buttons like a one-to-one chat. ConversationsPanel is a wx.Panel and can't be instantiated without a running wx.App, so the method under test is bound onto a plain stub carrying only the @@ -59,10 +60,16 @@ def test_ordinary_one_to_one_shows_both_call_buttons(): assert panel._video_call_btn.IsShown() is True -def test_group_newsletter_broadcast_hide_both_call_buttons(): +def test_group_shows_both_call_buttons(): + panel = _Stub() + panel._sync_voice_call_button("120363012345678901@g.us") + assert panel._voice_call_btn.IsShown() is True + assert panel._video_call_btn.IsShown() is True + + +def test_newsletter_and_broadcast_hide_both_call_buttons(): panel = _Stub() for jid in ( - "120363012345678901@g.us", "1234567890@newsletter", "status@broadcast", ): From 6c46f67cac7e2bffc28414c38af74144ca82447d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 14:02:27 +0000 Subject: [PATCH 2/3] Update voice-calls trap: group offers are answerable --- docs/traps/voice-calls.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/traps/voice-calls.md b/docs/traps/voice-calls.md index 6576b1d89..dc1698d89 100644 --- a/docs/traps/voice-calls.md +++ b/docs/traps/voice-calls.md @@ -695,4 +695,4 @@ A tester with speakers reported the other person hearing their own voice, only i - **AEC3 only reaches ~0.5 s.** Simulated on a loopback copy: ~70 dB of reduction up to 450 ms late, 1-2 dB from 600 ms, whatever the gain (even 4x and clipping). A mixer loopback can return later than that (a tester heard his whole voice back, 2026-10-19). `CallAudioProcessor` therefore measures the echo's delay itself once a second (`echo_coupling` up to 1.5 s, coupling >= 0.4, two estimates within 20 ms) and, past `DIRECT_ECHO_MAX_MS` (350), holds the reference back so AEC3 sees the echo ~150 ms after it (`reference_held_ms` in the log, plus an info line on every change). 900 ms late: 1 dB -> ~75 dB. Below the threshold nothing is held, and a headset never moves it. - **Defaults:** echo cancellation is on (`migrate_echo_cancellation_default()` turned it on once for existing installs, `settings-migrations.md`); noise suppression stays off. With a headset AEC3 finds no echo and leaves the voice alone. -**Answering from any window: two Windows hotkeys held only while they can act.** `main_window/call_hotkeys.py` registers them with `_HotkeyManager`: *answer* (shipped Ctrl+Alt+Shift+Enter) while a one-to-one offer rings, *end* (shipped Ctrl+Alt+Shift+Backspace) while any offer rings or a call is in progress. *Answer* accepts the call, then brings the call window in front of any program (`core/dialog_foreground.force_foreground(topmost=True)`, the helper the incoming-call popup uses too) and focuses its first button; a video call is answered like its Answer button, camera on. A group offer never takes *answer* (`first_answerable_call()` skips it, as the Answer button does). *End* rejects the first ringing offer, a group one included, before it would hang up the call in progress: a group call ringing during a one-to-one call must not cost the user the call they are on. Both are settings (`general.call_answer_hotkey` / `call_end_hotkey`, `{"vk": 0, "mod": 0}` = removed) edited as `global` rows of Settings > Keyboard shortcuts next to the show-WinZapp key; an import that puts one on the show-WinZapp key or on the other call key keeps the old value. A show-WinZapp key saved on a call key's combination (possible before the call keys shipped) keeps it: `call_hotkey_setting()` then reads that call key as removed, and `saved_call_hotkey()` is the raw value. The call keys retry a failed registration every second, not every 15 s, since another account lets go of the key as soon as its call is decided. `_sync_incoming_call_bar()` and `_sync_voice_call_bar()` (which `_stop_voice_call_audio()` queues at the end of a call) ask for a sync with `_request_call_hotkey_sync()`, which queues one `_sync_call_hotkeys()` behind a pending flag. Never sync in place: `accept_incoming_call()` stops the alert before it sets `_active_voice_call`, and a sync in between dropped the end key and took it again on every answer, each `stop(wait=True)` holding the UI thread. `_perform_shutdown()` calls `_release_call_hotkeys()` first, and both keys do nothing once `_shutting_down` is set: answering into a server being stopped opens call audio for nothing. Why they are not permanent: a system-wide Ctrl+Alt+Shift+Enter held all day would take that key from every other program. Several accounts open: each account is its own process and Windows gives a combination to one registration, so while account A holds *end* for its call, account B's ringing call cannot take it and the press meant for B reaches A. With nothing ringing locally, A therefore asks every other running account first (`ipc.request_reject_ringing_call`, off the wx thread) and hangs up only when none had a call ringing, and only if the same call is still in progress by then. +**Answering from any window: two Windows hotkeys held only while they can act.** `main_window/call_hotkeys.py` registers them with `_HotkeyManager`: *answer* (shipped Ctrl+Alt+Shift+Enter) while an offer rings, *end* (shipped Ctrl+Alt+Shift+Backspace) while any offer rings or a call is in progress. *Answer* accepts the call, then brings the call window in front of any program (`core/dialog_foreground.force_foreground(topmost=True)`, the helper the incoming-call popup uses too) and focuses its first button; a video call is answered like its Answer button, camera on. A group offer takes *answer* like a one-to-one one (`incoming_call_can_answer()` is always true; the page accepts it through the same native `acceptCall`), in oldest-first order. *End* rejects the first ringing offer, a group one included, before it would hang up the call in progress: a group call ringing during a one-to-one call must not cost the user the call they are on. Both are settings (`general.call_answer_hotkey` / `call_end_hotkey`, `{"vk": 0, "mod": 0}` = removed) edited as `global` rows of Settings > Keyboard shortcuts next to the show-WinZapp key; an import that puts one on the show-WinZapp key or on the other call key keeps the old value. A show-WinZapp key saved on a call key's combination (possible before the call keys shipped) keeps it: `call_hotkey_setting()` then reads that call key as removed, and `saved_call_hotkey()` is the raw value. The call keys retry a failed registration every second, not every 15 s, since another account lets go of the key as soon as its call is decided. `_sync_incoming_call_bar()` and `_sync_voice_call_bar()` (which `_stop_voice_call_audio()` queues at the end of a call) ask for a sync with `_request_call_hotkey_sync()`, which queues one `_sync_call_hotkeys()` behind a pending flag. Never sync in place: `accept_incoming_call()` stops the alert before it sets `_active_voice_call`, and a sync in between dropped the end key and took it again on every answer, each `stop(wait=True)` holding the UI thread. `_perform_shutdown()` calls `_release_call_hotkeys()` first, and both keys do nothing once `_shutting_down` is set: answering into a server being stopped opens call audio for nothing. Why they are not permanent: a system-wide Ctrl+Alt+Shift+Enter held all day would take that key from every other program. Several accounts open: each account is its own process and Windows gives a combination to one registration, so while account A holds *end* for its call, account B's ringing call cannot take it and the press meant for B reaches A. With nothing ringing locally, A therefore asks every other running account first (`ipc.request_reject_ringing_call`, off the wx thread) and hangs up only when none had a call ringing, and only if the same call is still in progress by then. From 87a01d86dd919e814832250222b47bd773a18ff3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 14:07:10 +0000 Subject: [PATCH 3/3] =?UTF-8?q?Guia=20de=20uso:=20chamadas=20em=20grupo=20?= =?UTF-8?q?e=20detec=C3=A7=C3=A3o=20de=20c=C3=A2mera?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/data/help_guide/en-US.html | 1 + client/data/help_guide/es-ES.html | 1 + client/data/help_guide/pl.html | 1 + client/data/help_guide/pt-BR.html | 1 + client/data/help_guide/pt-PT.html | 1 + client/data/help_guide/ro.html | 1 + client/data/help_guide/tr-TR.html | 1 + docs/user-guide/cobertura.json | 2 ++ docs/user-guide/secoes/en-US/09-ligacoes.html | 1 + docs/user-guide/secoes/es-ES/09-ligacoes.html | 1 + docs/user-guide/secoes/pl/09-ligacoes.html | 1 + docs/user-guide/secoes/pt-BR/09-ligacoes.html | 1 + docs/user-guide/secoes/pt-PT/09-ligacoes.html | 1 + docs/user-guide/secoes/ro/09-ligacoes.html | 1 + docs/user-guide/secoes/tr-TR/09-ligacoes.html | 1 + 15 files changed, 16 insertions(+) diff --git a/client/data/help_guide/en-US.html b/client/data/help_guide/en-US.html index 06a4942fa..36e3f3b75 100644 --- a/client/data/help_guide/en-US.html +++ b/client/data/help_guide/en-US.html @@ -687,6 +687,7 @@

📞 9. Calls

Make a call

In an individual chat, use the "Voice call" (Ctrl+Shift+V) and "Video call" (Ctrl+Alt+Shift+V) buttons; the shortcuts also work with the message field focused. The screen reader announces when the call connects, when it is reconnecting and when it ends.

+

In a group chat the same buttons call the whole group. An incoming group call rings, is announced by the group's name and is answered like any other call, and the camera can be turned on during the call. WinZapp finds the camera with the FFmpeg it ships, so a video call no longer goes out receive-only.

During the call

diff --git a/client/data/help_guide/es-ES.html b/client/data/help_guide/es-ES.html index 766c37ab9..84a0e7332 100644 --- a/client/data/help_guide/es-ES.html +++ b/client/data/help_guide/es-ES.html @@ -687,6 +687,7 @@

📞 9. Llamadas

Hacer una llamada

En un chat individual, usa los botones "Llamada de voz" (Ctrl+Shift+V) y "Videollamada" (Ctrl+Alt+Shift+V); los atajos también funcionan con el foco en el campo de mensaje. El lector de pantalla anuncia cuándo se conecta la llamada, cuándo se está reconectando y cuándo termina.

+

En un chat de grupo, los mismos botones llaman a todo el grupo. Una llamada de grupo entrante suena, se anuncia con el nombre del grupo y se contesta como cualquier otra llamada, y se puede activar la cámara durante la llamada. WinZapp encuentra la cámara con el FFmpeg que incluye, así que una videollamada ya no sale solo recibiendo.

Durante la llamada

diff --git a/client/data/help_guide/pl.html b/client/data/help_guide/pl.html index 58fae1e20..93ca9bc55 100644 --- a/client/data/help_guide/pl.html +++ b/client/data/help_guide/pl.html @@ -687,6 +687,7 @@

📞 9. Połączenia

Wykonanie połączenia

W czacie indywidualnym użyj przycisków "Połączenie głosowe" (Ctrl+Shift+V) i "Połączenie wideo" (Ctrl+Alt+Shift+V); skróty działają także przy fokusie w polu wiadomości. Czytnik ekranu ogłasza, kiedy połączenie zostaje nawiązane, kiedy się łączy ponownie i kiedy się kończy.

+

W czacie grupowym te same przyciski dzwonią do całej grupy. Przychodzące połączenie grupowe dzwoni, jest ogłaszane nazwą grupy i odbiera się je jak każde inne, a kamerę można włączyć w trakcie połączenia. WinZapp znajduje kamerę za pomocą dołączonego FFmpeg, więc połączenie wideo nie jest już tylko odbierające.

Podczas połączenia

diff --git a/client/data/help_guide/pt-BR.html b/client/data/help_guide/pt-BR.html index a81d562fe..9c0637f1d 100644 --- a/client/data/help_guide/pt-BR.html +++ b/client/data/help_guide/pt-BR.html @@ -687,6 +687,7 @@

📞 9. Ligações

Ligar

Numa conversa individual, use os botões "Ligação de voz" (Ctrl+Shift+V) e "Ligação de vídeo" (Ctrl+Alt+Shift+V); os atalhos funcionam também com o campo de mensagem em foco. O leitor de tela avisa quando a ligação conecta, quando está reconectando e quando termina.

+

Numa conversa em grupo os mesmos botões ligam para o grupo todo. Uma ligação de grupo recebida toca, é anunciada pelo nome do grupo e é atendida como qualquer outra ligação, e dá para ligar a câmera durante a ligação. O WinZapp encontra a câmera com o FFmpeg que acompanha o programa, então uma ligação de vídeo não sai mais só recebendo.

Durante a ligação

diff --git a/client/data/help_guide/pt-PT.html b/client/data/help_guide/pt-PT.html index f00ec631f..b07da68cf 100644 --- a/client/data/help_guide/pt-PT.html +++ b/client/data/help_guide/pt-PT.html @@ -687,6 +687,7 @@

📞 9. Ligações

Ligar

Numa conversa individual, use os botões "Chamada de voz" (Ctrl+Shift+V) e "Chamada de vídeo" (Ctrl+Alt+Shift+V); os atalhos funcionam também com o campo de mensagem em foco. O leitor de ecrã avisa quando a chamada liga, quando está a religar e quando termina.

+

Numa conversa de grupo, os mesmos botões ligam para o grupo todo. Uma chamada de grupo recebida toca, é anunciada pelo nome do grupo e é atendida como qualquer outra chamada, sendo possível ligar a câmara durante a chamada. O WinZapp encontra a câmara com o FFmpeg que acompanha o programa, pelo que uma chamada de vídeo já não sai apenas a receber.

Durante a chamada

diff --git a/client/data/help_guide/ro.html b/client/data/help_guide/ro.html index 7c01732cc..51458808d 100644 --- a/client/data/help_guide/ro.html +++ b/client/data/help_guide/ro.html @@ -687,6 +687,7 @@

📞 9. Apeluri

Efectuarea unui apel

Într-o conversație individuală, folosește butoanele "Apel vocal" (Ctrl+Shift+V) și "Apel video" (Ctrl+Alt+Shift+V); comenzile rapide funcționează și cu focalizarea pe câmpul de mesaj. Cititorul de ecran anunță când se conectează apelul, când se reconectează și când se termină.

+

Într-o conversație de grup, aceleași butoane sună tot grupul. Un apel de grup primit sună, este anunțat cu numele grupului și se răspunde ca la orice alt apel, iar camera poate fi pornită în timpul apelului. WinZapp găsește camera cu FFmpeg-ul inclus, deci un apel video nu mai pleacă doar în recepție.

În timpul apelului

diff --git a/client/data/help_guide/tr-TR.html b/client/data/help_guide/tr-TR.html index 764e92db2..174453e05 100644 --- a/client/data/help_guide/tr-TR.html +++ b/client/data/help_guide/tr-TR.html @@ -687,6 +687,7 @@

📞 9. Aramalar

Arama yapma

Bireysel bir sohbette "Sesli arama" (Ctrl+Shift+V) ve "Görüntülü arama" (Ctrl+Alt+Shift+V) düğmelerini kullanın; kısayollar odak mesaj alanındayken de çalışır. Ekran okuyucu aramanın ne zaman bağlandığını, ne zaman yeniden bağlandığını ve ne zaman bittiğini duyurur.

+

Grup sohbetinde aynı düğmeler tüm grubu arar. Gelen grup araması çalar, grubun adıyla duyurulur ve diğer aramalar gibi yanıtlanır; kamera arama sırasında açılabilir. WinZapp kamerayı birlikte gelen FFmpeg ile bulur, bu yüzden görüntülü arama artık yalnızca alma modunda başlamaz.

Arama sırasında

diff --git a/docs/user-guide/cobertura.json b/docs/user-guide/cobertura.json index 8a23750a7..067d74459 100644 --- a/docs/user-guide/cobertura.json +++ b/docs/user-guide/cobertura.json @@ -4,6 +4,8 @@ "commit": "4b8a5c6a", "data": "14/10/2026", "itens": { + "05947e90a05a": "Chamadas em grupo: os botões de chamada de voz e de vídeo agora aparecem na conv", + "8dc1cc789b43": "Chamadas de vídeo: o WinZapp agora encontra a câmera com o FFmpeg incluído no Wi", "ebe407996699": "Os botões das mensagens de empresas agora podem ser usados: um botão de resposta", "b3db49355a14": "O WPPConnect Server 2.10.61 agora é oferecido como atualização sem esperar por u", "57b296f613e7": "As atualizações do macOS explicam se a verificação falhou por um problema de con", diff --git a/docs/user-guide/secoes/en-US/09-ligacoes.html b/docs/user-guide/secoes/en-US/09-ligacoes.html index f1150f724..591a1c39f 100644 --- a/docs/user-guide/secoes/en-US/09-ligacoes.html +++ b/docs/user-guide/secoes/en-US/09-ligacoes.html @@ -3,6 +3,7 @@

Make a call

In an individual chat, use the "Voice call" (Ctrl+Shift+V) and "Video call" (Ctrl+Alt+Shift+V) buttons; the shortcuts also work with the message field focused. The screen reader announces when the call connects, when it is reconnecting and when it ends.

+

In a group chat the same buttons call the whole group. An incoming group call rings, is announced by the group's name and is answered like any other call, and the camera can be turned on during the call. WinZapp finds the camera with the FFmpeg it ships, so a video call no longer goes out receive-only.

During the call

diff --git a/docs/user-guide/secoes/es-ES/09-ligacoes.html b/docs/user-guide/secoes/es-ES/09-ligacoes.html index 1573d3cfa..6cadafad4 100644 --- a/docs/user-guide/secoes/es-ES/09-ligacoes.html +++ b/docs/user-guide/secoes/es-ES/09-ligacoes.html @@ -3,6 +3,7 @@

Hacer una llamada

En un chat individual, usa los botones "Llamada de voz" (Ctrl+Shift+V) y "Videollamada" (Ctrl+Alt+Shift+V); los atajos también funcionan con el foco en el campo de mensaje. El lector de pantalla anuncia cuándo se conecta la llamada, cuándo se está reconectando y cuándo termina.

+

En un chat de grupo, los mismos botones llaman a todo el grupo. Una llamada de grupo entrante suena, se anuncia con el nombre del grupo y se contesta como cualquier otra llamada, y se puede activar la cámara durante la llamada. WinZapp encuentra la cámara con el FFmpeg que incluye, así que una videollamada ya no sale solo recibiendo.

Durante la llamada

diff --git a/docs/user-guide/secoes/pl/09-ligacoes.html b/docs/user-guide/secoes/pl/09-ligacoes.html index 7fcf8fe11..2b4a81a5e 100644 --- a/docs/user-guide/secoes/pl/09-ligacoes.html +++ b/docs/user-guide/secoes/pl/09-ligacoes.html @@ -3,6 +3,7 @@

Wykonanie połączenia

W czacie indywidualnym użyj przycisków "Połączenie głosowe" (Ctrl+Shift+V) i "Połączenie wideo" (Ctrl+Alt+Shift+V); skróty działają także przy fokusie w polu wiadomości. Czytnik ekranu ogłasza, kiedy połączenie zostaje nawiązane, kiedy się łączy ponownie i kiedy się kończy.

+

W czacie grupowym te same przyciski dzwonią do całej grupy. Przychodzące połączenie grupowe dzwoni, jest ogłaszane nazwą grupy i odbiera się je jak każde inne, a kamerę można włączyć w trakcie połączenia. WinZapp znajduje kamerę za pomocą dołączonego FFmpeg, więc połączenie wideo nie jest już tylko odbierające.

Podczas połączenia

diff --git a/docs/user-guide/secoes/pt-BR/09-ligacoes.html b/docs/user-guide/secoes/pt-BR/09-ligacoes.html index b19f9adec..0675518ef 100644 --- a/docs/user-guide/secoes/pt-BR/09-ligacoes.html +++ b/docs/user-guide/secoes/pt-BR/09-ligacoes.html @@ -3,6 +3,7 @@

Ligar

Numa conversa individual, use os botões "Ligação de voz" (Ctrl+Shift+V) e "Ligação de vídeo" (Ctrl+Alt+Shift+V); os atalhos funcionam também com o campo de mensagem em foco. O leitor de tela avisa quando a ligação conecta, quando está reconectando e quando termina.

+

Numa conversa em grupo os mesmos botões ligam para o grupo todo. Uma ligação de grupo recebida toca, é anunciada pelo nome do grupo e é atendida como qualquer outra ligação, e dá para ligar a câmera durante a ligação. O WinZapp encontra a câmera com o FFmpeg que acompanha o programa, então uma ligação de vídeo não sai mais só recebendo.

Durante a ligação

diff --git a/docs/user-guide/secoes/pt-PT/09-ligacoes.html b/docs/user-guide/secoes/pt-PT/09-ligacoes.html index d6e6b0cb7..5a9cc7b7e 100644 --- a/docs/user-guide/secoes/pt-PT/09-ligacoes.html +++ b/docs/user-guide/secoes/pt-PT/09-ligacoes.html @@ -3,6 +3,7 @@

Ligar

Numa conversa individual, use os botões "Chamada de voz" (Ctrl+Shift+V) e "Chamada de vídeo" (Ctrl+Alt+Shift+V); os atalhos funcionam também com o campo de mensagem em foco. O leitor de ecrã avisa quando a chamada liga, quando está a religar e quando termina.

+

Numa conversa de grupo, os mesmos botões ligam para o grupo todo. Uma chamada de grupo recebida toca, é anunciada pelo nome do grupo e é atendida como qualquer outra chamada, sendo possível ligar a câmara durante a chamada. O WinZapp encontra a câmara com o FFmpeg que acompanha o programa, pelo que uma chamada de vídeo já não sai apenas a receber.

Durante a chamada

diff --git a/docs/user-guide/secoes/ro/09-ligacoes.html b/docs/user-guide/secoes/ro/09-ligacoes.html index faff81026..4b8ad6c3a 100644 --- a/docs/user-guide/secoes/ro/09-ligacoes.html +++ b/docs/user-guide/secoes/ro/09-ligacoes.html @@ -3,6 +3,7 @@

Efectuarea unui apel

Într-o conversație individuală, folosește butoanele "Apel vocal" (Ctrl+Shift+V) și "Apel video" (Ctrl+Alt+Shift+V); comenzile rapide funcționează și cu focalizarea pe câmpul de mesaj. Cititorul de ecran anunță când se conectează apelul, când se reconectează și când se termină.

+

Într-o conversație de grup, aceleași butoane sună tot grupul. Un apel de grup primit sună, este anunțat cu numele grupului și se răspunde ca la orice alt apel, iar camera poate fi pornită în timpul apelului. WinZapp găsește camera cu FFmpeg-ul inclus, deci un apel video nu mai pleacă doar în recepție.

În timpul apelului

diff --git a/docs/user-guide/secoes/tr-TR/09-ligacoes.html b/docs/user-guide/secoes/tr-TR/09-ligacoes.html index 77e11b328..c8b339d3f 100644 --- a/docs/user-guide/secoes/tr-TR/09-ligacoes.html +++ b/docs/user-guide/secoes/tr-TR/09-ligacoes.html @@ -3,6 +3,7 @@

Arama yapma

Bireysel bir sohbette "Sesli arama" (Ctrl+Shift+V) ve "Görüntülü arama" (Ctrl+Alt+Shift+V) düğmelerini kullanın; kısayollar odak mesaj alanındayken de çalışır. Ekran okuyucu aramanın ne zaman bağlandığını, ne zaman yeniden bağlandığını ve ne zaman bittiğini duyurur.

+

Grup sohbetinde aynı düğmeler tüm grubu arar. Gelen grup araması çalar, grubun adıyla duyurulur ve diğer aramalar gibi yanıtlanır; kamera arama sırasında açılabilir. WinZapp kamerayı birlikte gelen FFmpeg ile bulur, bu yüzden görüntülü arama artık yalnızca alma modunda başlamaz.

Arama sırasında