|
|
@@ -14,8 +14,9 @@ use crate::autoreply::{
|
|
|
};
|
|
|
use crate::config::Config;
|
|
|
use crate::history::{ChatSummary, Contact, History, StoredMessage};
|
|
|
+use crate::transcribe::TranscriptionJob;
|
|
|
use crate::web::{self, WebChat, WebCommand, WebRequest, WebState};
|
|
|
-use crate::whatsapp::{ImageRef, WaEvent, WaHandle};
|
|
|
+use crate::whatsapp::{AudioRef, DownloadedAudio, ImageRef, WaEvent, WaHandle, is_voice_note};
|
|
|
|
|
|
/// Maximum number of messages of the open chat kept in `App::messages`.
|
|
|
pub const MESSAGES_SHOWN: usize = 200;
|
|
|
@@ -34,6 +35,10 @@ const QUIT_FUNCTION_KEY: u8 = 10;
|
|
|
const LIST_PAGE_ROWS: isize = 10;
|
|
|
/// Sender jid stored for messages written by the user (own account).
|
|
|
const OWN_SENDER_JID: &str = "";
|
|
|
+/// Text of a message carrying a voice note only, used as prefix of its transcription.
|
|
|
+/// shortcut: duplicates the private VOICE_PLACEHOLDER in whatsapp.rs; make it pub and reuse when
|
|
|
+/// whatsapp.rs is next touched.
|
|
|
+const VOICE_NOTE_TEXT: &str = "[messaggio vocale]";
|
|
|
|
|
|
/// Status shown once the WhatsApp session is connected.
|
|
|
const STATUS_CONNECTED: &str = "Connesso";
|
|
|
@@ -62,16 +67,22 @@ const STATUS_AUTOREPLY_UNSUPPORTED: &str = "Autoreply disponibile solo nelle cha
|
|
|
const STATUS_IMAGE_DOWNLOAD_PREFIX: &str = "Download immagine fallito";
|
|
|
/// Prefix of the status shown when an image description cannot be obtained.
|
|
|
const STATUS_IMAGE_DESCRIPTION_PREFIX: &str = "Descrizione immagine fallita";
|
|
|
+/// Prefix of the status shown when the audio to transcribe cannot be downloaded.
|
|
|
+const STATUS_AUDIO_DOWNLOAD_PREFIX: &str = "Download audio fallito";
|
|
|
+/// Prefix of the status shown when an audio transcription cannot be obtained.
|
|
|
+const STATUS_AUDIO_TRANSCRIPTION_PREFIX: &str = "Trascrizione vocale fallita";
|
|
|
/// Maximum number of new images described before one reply (the most recent ones are kept).
|
|
|
const MAX_IMAGES_PER_REPLY: usize = 5;
|
|
|
-/// Attempts made to download an image before giving up.
|
|
|
-const IMAGE_DOWNLOAD_ATTEMPTS: u32 = 3;
|
|
|
-/// Base wait, in seconds, between image download attempts (multiplied by the attempt number).
|
|
|
-const IMAGE_DOWNLOAD_RETRY_SECONDS: u64 = 2;
|
|
|
-/// Seconds after which an image download attempt is abandoned.
|
|
|
-const IMAGE_DOWNLOAD_TIMEOUT_SECONDS: u64 = 60;
|
|
|
-/// Error text of an image download attempt abandoned after `IMAGE_DOWNLOAD_TIMEOUT_SECONDS`.
|
|
|
-const IMAGE_DOWNLOAD_TIMEOUT_MESSAGE: &str = "tempo scaduto";
|
|
|
+/// Maximum number of new audios transcribed before one reply (the most recent ones are kept).
|
|
|
+const MAX_AUDIOS_PER_REPLY: usize = 3;
|
|
|
+/// Attempts made to download an image or an audio before giving up.
|
|
|
+const MEDIA_DOWNLOAD_ATTEMPTS: u32 = 3;
|
|
|
+/// Base wait, in seconds, between media download attempts (multiplied by the attempt number).
|
|
|
+const MEDIA_DOWNLOAD_RETRY_SECONDS: u64 = 2;
|
|
|
+/// Seconds after which a media download attempt is abandoned.
|
|
|
+const MEDIA_DOWNLOAD_TIMEOUT_SECONDS: u64 = 60;
|
|
|
+/// Error text of a media download attempt abandoned after `MEDIA_DOWNLOAD_TIMEOUT_SECONDS`.
|
|
|
+const MEDIA_DOWNLOAD_TIMEOUT_MESSAGE: &str = "tempo scaduto";
|
|
|
/// Prefix of the status shown when the history lookup fails.
|
|
|
const STATUS_HISTORY_ERROR_PREFIX: &str = "Errore storico";
|
|
|
/// Prefix of the status shown when a message cannot be sent.
|
|
|
@@ -123,6 +134,15 @@ pub enum AppEvent {
|
|
|
target: DescriptionTarget,
|
|
|
results: Vec<(String, Result<String, String>)>,
|
|
|
},
|
|
|
+ /// Outcome of the transcriptions of the audios of one chat: one result per audio, with the
|
|
|
+ /// id of the message carrying it; `images` are the postponed images to describe afterwards
|
|
|
+ /// and `then_reply` whether the chat must be answered when work finishes.
|
|
|
+ AudiosTranscribed {
|
|
|
+ chat_jid: String,
|
|
|
+ then_reply: bool,
|
|
|
+ results: Vec<(String, Result<String, String>)>,
|
|
|
+ images: Vec<(String, ImageRef)>,
|
|
|
+ },
|
|
|
/// The reply delay of `chat_jid` ended: its scheduled reply, if still pending, must be sent.
|
|
|
ReplyDue { chat_jid: String },
|
|
|
/// Outcome of a send request; `Ok` carries the message id.
|
|
|
@@ -222,6 +242,14 @@ pub struct App {
|
|
|
backlog_images: HashMap<String, Vec<(String, ImageRef)>>,
|
|
|
/// Message ids of the images whose description is in progress.
|
|
|
describing: HashSet<String>,
|
|
|
+ /// Audios received by each chat with a pending reply, keyed by chat JID, in arrival order,
|
|
|
+ /// each with the id of the message carrying it.
|
|
|
+ pending_audios: HashMap<String, Vec<(String, AudioRef)>>,
|
|
|
+ /// Audios delivered with the offline messages before the catch-up, same layout as
|
|
|
+ /// `pending_audios`; emptied by the catch-up.
|
|
|
+ backlog_audios: HashMap<String, Vec<(String, AudioRef)>>,
|
|
|
+ /// Message ids of the audios whose transcription is in progress.
|
|
|
+ transcribing: HashSet<String>,
|
|
|
/// Id of the last message in the context of the reply in progress, keyed by chat JID.
|
|
|
answered_up_to: HashMap<String, String>,
|
|
|
/// Whether the gate has been given a non-empty set of own JIDs.
|
|
|
@@ -274,6 +302,9 @@ impl App {
|
|
|
pending_images: HashMap::new(),
|
|
|
backlog_images: HashMap::new(),
|
|
|
describing: HashSet::new(),
|
|
|
+ pending_audios: HashMap::new(),
|
|
|
+ backlog_audios: HashMap::new(),
|
|
|
+ transcribing: HashSet::new(),
|
|
|
answered_up_to: HashMap::new(),
|
|
|
own_jids_known: false,
|
|
|
catch_up_done: false,
|
|
|
@@ -300,6 +331,9 @@ impl App {
|
|
|
self.finish_reply(&chat_jid)
|
|
|
}
|
|
|
AppEvent::ImagesDescribed { target, results } => self.handle_images_described(target, results),
|
|
|
+ AppEvent::AudiosTranscribed { chat_jid, then_reply, results, images } => {
|
|
|
+ self.handle_audios_transcribed(&chat_jid, then_reply, results, images)
|
|
|
+ }
|
|
|
AppEvent::ReplyDue { chat_jid } => self.handle_reply_due(&chat_jid),
|
|
|
AppEvent::Sent { chat_jid, text, is_ai, result } => {
|
|
|
self.handle_sent(OutgoingMessage { chat_jid, text, is_ai }, result)
|
|
|
@@ -423,7 +457,7 @@ impl App {
|
|
|
}
|
|
|
WaEvent::OfflineSyncCompleted => self.on_offline_sync_completed()?,
|
|
|
WaEvent::Contact(contact) => self.on_contact(&contact)?,
|
|
|
- WaEvent::Message { message, image } => self.on_message(&message, image)?,
|
|
|
+ WaEvent::Message { message, image, audio } => self.on_message(&message, image, audio)?,
|
|
|
}
|
|
|
Ok(())
|
|
|
}
|
|
|
@@ -444,10 +478,16 @@ impl App {
|
|
|
|
|
|
/// Purpose: store an incoming/synced message and, if new, refresh views and maybe autoreply.
|
|
|
/// Inputs: `message`, the message received from WhatsApp; `image`, the reference to its
|
|
|
- /// image when it is a live image message.
|
|
|
+ /// image when it is a live image message; `audio`, the reference to its audio when it is a
|
|
|
+ /// live audio message.
|
|
|
/// Output: `Ok(())`, or a history error.
|
|
|
/// Side effects: writes the history; reloads chats/messages; may schedule an AI reply.
|
|
|
- fn on_message(&mut self, message: &StoredMessage, image: Option<ImageRef>) -> Result<()> {
|
|
|
+ fn on_message(
|
|
|
+ &mut self,
|
|
|
+ message: &StoredMessage,
|
|
|
+ image: Option<ImageRef>,
|
|
|
+ audio: Option<AudioRef>,
|
|
|
+ ) -> Result<()> {
|
|
|
if !self.history.save_message(message)? {
|
|
|
return Ok(());
|
|
|
}
|
|
|
@@ -455,17 +495,23 @@ impl App {
|
|
|
if self.open_chat.as_deref() == Some(message.chat_jid.as_str()) {
|
|
|
self.reload_messages()?;
|
|
|
}
|
|
|
- self.maybe_autoreply(message, image)
|
|
|
+ self.maybe_autoreply(message, image, audio)
|
|
|
}
|
|
|
|
|
|
/// Purpose: schedule an AI reply to `message`, sent `reply_delay_seconds` later, if the gate
|
|
|
- /// allows it (autoreply enabled globally or for its chat), and keep its image to describe.
|
|
|
- /// Inputs: `message`, the newly stored message; `image`, the reference to its image, if any.
|
|
|
+ /// allows it (autoreply enabled globally or for its chat), and keep its media to describe.
|
|
|
+ /// Inputs: `message`, the newly stored message; `image`, the reference to its image, if any;
|
|
|
+ /// `audio`, the reference to its audio, if any.
|
|
|
/// Output: `Ok(())`, or a history error.
|
|
|
/// Side effects: may schedule a reply (see `schedule_reply`); a chat already pending (waiting
|
|
|
- /// or being answered) gets no second timer. Images sent by the contact in a chat with the
|
|
|
- /// autoreply enabled are handled by `keep_image`.
|
|
|
- fn maybe_autoreply(&mut self, message: &StoredMessage, image: Option<ImageRef>) -> Result<()> {
|
|
|
+ /// or being answered) gets no second timer. Images and audios sent by the contact in a chat
|
|
|
+ /// with the autoreply enabled are handled by `keep_image` and `keep_audio`.
|
|
|
+ fn maybe_autoreply(
|
|
|
+ &mut self,
|
|
|
+ message: &StoredMessage,
|
|
|
+ image: Option<ImageRef>,
|
|
|
+ audio: Option<AudioRef>,
|
|
|
+ ) -> Result<()> {
|
|
|
if self.ai_setup().is_none() {
|
|
|
return Ok(());
|
|
|
}
|
|
|
@@ -476,10 +522,13 @@ impl App {
|
|
|
if self.gate.should_schedule(message, enabled) {
|
|
|
self.schedule_reply(&message.chat_jid);
|
|
|
}
|
|
|
- match image.filter(|_| enabled && self.gate.sender_allowed(message)) {
|
|
|
- Some(image) => self.keep_image(message, image),
|
|
|
- None => Ok(()),
|
|
|
+ if let Some(image) = image.filter(|_| enabled && self.gate.sender_allowed(message)) {
|
|
|
+ self.keep_image(message, image)?;
|
|
|
+ }
|
|
|
+ if let Some(audio) = audio.filter(|_| enabled && self.gate.sender_allowed(message)) {
|
|
|
+ self.keep_audio(message, audio)?;
|
|
|
}
|
|
|
+ Ok(())
|
|
|
}
|
|
|
|
|
|
/// Purpose: start the reply delay of a chat.
|
|
|
@@ -531,33 +580,95 @@ impl App {
|
|
|
Ok(())
|
|
|
}
|
|
|
|
|
|
+ /// Purpose: keep an audio sent by the contact until it can be transcribed.
|
|
|
+ /// Inputs: `message`, the audio message; `audio`, its reference.
|
|
|
+ /// Output: `Ok(())`, or a history error.
|
|
|
+ /// Side effects: nothing when transcription is not configured; otherwise, a chat with a pending
|
|
|
+ /// reply keeps it for that reply; an offline audio (sent before the start) is kept for the
|
|
|
+ /// catch-up while it has not run yet, and is transcribed at once, without reply, when it
|
|
|
+ /// arrives after the catch-up.
|
|
|
+ fn keep_audio(&mut self, message: &StoredMessage, audio: AudioRef) -> Result<()> {
|
|
|
+ // Nothing would consume them: audios kept now stay untranscribed if transcription is
|
|
|
+ // enabled later, which is acceptable for the replies already in flight.
|
|
|
+ if !self.transcription_enabled() {
|
|
|
+ return Ok(());
|
|
|
+ }
|
|
|
+ let chat_jid = message.chat_jid.clone();
|
|
|
+ let entry = (message.message_id.clone(), audio);
|
|
|
+
|
|
|
+ // Same as `keep_image`: an audio sent by the contact after the start always schedules a
|
|
|
+ // reply first (see `maybe_autoreply`), so its chat is already pending here.
|
|
|
+ if self.gate.is_pending(&chat_jid) {
|
|
|
+ self.pending_audios.entry(chat_jid).or_default().push(entry);
|
|
|
+ } else if self.gate.is_backlog(message) && !self.catch_up_done {
|
|
|
+ self.backlog_audios.entry(chat_jid).or_default().push(entry);
|
|
|
+ } else if self.gate.is_backlog(message) {
|
|
|
+ self.transcribe_late_audio(&chat_jid, entry)?;
|
|
|
+ }
|
|
|
+ Ok(())
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Purpose: transcribe an offline audio delivered after the catch-up, without answering.
|
|
|
+ /// Inputs: `chat_jid`, its chat; `entry`, the message id and the audio reference.
|
|
|
+ /// Output: `Ok(())`, or a history error.
|
|
|
+ /// Side effects: when the audio still needs a transcription (see `audios_to_transcribe`),
|
|
|
+ /// spawns its transcription task.
|
|
|
+ fn transcribe_late_audio(&mut self, chat_jid: &str, entry: (String, AudioRef)) -> Result<()> {
|
|
|
+ if !self.transcription_enabled() {
|
|
|
+ return Ok(());
|
|
|
+ }
|
|
|
+ let context = self.load_context(chat_jid)?;
|
|
|
+ let audios = self.audios_to_transcribe(&context, vec![entry]);
|
|
|
+ if !audios.is_empty() {
|
|
|
+ self.spawn_transcriptions(chat_jid, audios, Vec::new(), false);
|
|
|
+ }
|
|
|
+ Ok(())
|
|
|
+ }
|
|
|
+
|
|
|
/// Purpose: start the scheduled reply of a chat whose reply delay has elapsed.
|
|
|
/// Inputs: `chat_jid`, the chat whose timer fired.
|
|
|
/// Output: `Ok(())`, or a history error.
|
|
|
- /// Side effects: consumes the kept images; if the chat is still pending (not deleted
|
|
|
- /// meanwhile), continues with `prepare_reply`. The chat stays pending until `finish_reply`.
|
|
|
+ /// Side effects: consumes the kept images and audios; if the chat is still pending (not
|
|
|
+ /// deleted meanwhile), continues with `prepare_reply`. The chat stays pending until
|
|
|
+ /// `finish_reply`.
|
|
|
fn handle_reply_due(&mut self, chat_jid: &str) -> Result<()> {
|
|
|
let images = self.pending_images.remove(chat_jid).unwrap_or_default();
|
|
|
+ let audios = self.pending_audios.remove(chat_jid).unwrap_or_default();
|
|
|
if !self.gate.is_pending(chat_jid) {
|
|
|
return Ok(());
|
|
|
}
|
|
|
- self.prepare_reply(chat_jid, images).map(|_| ())
|
|
|
+ self.prepare_reply(chat_jid, images, audios).map(|_| ())
|
|
|
}
|
|
|
|
|
|
- /// Purpose: describe the new images of a pending chat, then answer it.
|
|
|
+ /// Purpose: transcribe the new audios of a pending chat and describe its new images, then
|
|
|
+ /// answer it.
|
|
|
/// Inputs: `chat_jid`, the chat to answer; `images`, the kept images of the messages being
|
|
|
- /// answered, each with the id of the message carrying it.
|
|
|
- /// Output: `true` if the reply goes ahead (now or after the descriptions), `false` if it is
|
|
|
- /// no longer wanted; or a history error.
|
|
|
- /// Side effects: when a reply is still wanted (see `reply_context`) and some images need a
|
|
|
- /// description (see `images_to_describe`), spawns their description task, whose
|
|
|
+ /// answered, each with the id of the message carrying it; `audios`, the kept audios, same
|
|
|
+ /// layout.
|
|
|
+ /// Output: `true` if the reply goes ahead (now or after the descriptions/transcriptions),
|
|
|
+ /// `false` if it is no longer wanted; or a history error.
|
|
|
+ /// Side effects: when a reply is still wanted (see `reply_context`) and some audios need a
|
|
|
+ /// transcription (see `audios_to_transcribe`), spawns their transcription task, whose
|
|
|
+ /// `AudiosTranscribed` event describes the images and sends the reply; when only images need
|
|
|
+ /// a description (see `images_to_describe`), spawns their description task, whose
|
|
|
/// `ImagesDescribed` event sends the reply; otherwise spawns the reply at once. When no reply
|
|
|
/// is wanted, ends the pending state (see `finish_reply`).
|
|
|
- fn prepare_reply(&mut self, chat_jid: &str, images: Vec<(String, ImageRef)>) -> Result<bool> {
|
|
|
+ fn prepare_reply(
|
|
|
+ &mut self,
|
|
|
+ chat_jid: &str,
|
|
|
+ images: Vec<(String, ImageRef)>,
|
|
|
+ audios: Vec<(String, AudioRef)>,
|
|
|
+ ) -> Result<bool> {
|
|
|
let Some((ai, prompt, context)) = self.reply_context(chat_jid)? else {
|
|
|
self.finish_reply(chat_jid)?;
|
|
|
return Ok(false);
|
|
|
};
|
|
|
+ let audios = self.audios_to_transcribe(&context, audios);
|
|
|
+ if !audios.is_empty() {
|
|
|
+ // The images wait for the transcriptions: `handle_audios_transcribed` describes them.
|
|
|
+ self.spawn_transcriptions(chat_jid, audios, images, true);
|
|
|
+ return Ok(true);
|
|
|
+ }
|
|
|
let images = self.images_to_describe(chat_jid, &context, images, ai.has_vision())?;
|
|
|
if images.is_empty() {
|
|
|
self.start_reply(ai, &prompt, chat_jid, &context)?;
|
|
|
@@ -567,6 +678,51 @@ impl App {
|
|
|
Ok(true)
|
|
|
}
|
|
|
|
|
|
+ /// Purpose: spawn the transcription of audios, tracking them so none is transcribed twice.
|
|
|
+ /// Inputs: `chat_jid`, their chat; `audios`, the audios with the ids of their messages;
|
|
|
+ /// `images`, the images to describe once they are transcribed; `then_reply`, whether the chat
|
|
|
+ /// must be answered when the work finishes.
|
|
|
+ /// Output: none.
|
|
|
+ /// Side effects: adds the message ids to `transcribing`; spawns the transcription tasks (see
|
|
|
+ /// `spawn_audio_transcriptions`). Nothing happens when transcription is not configured.
|
|
|
+ fn spawn_transcriptions(
|
|
|
+ &mut self,
|
|
|
+ chat_jid: &str,
|
|
|
+ audios: Vec<(String, AudioRef)>,
|
|
|
+ images: Vec<(String, ImageRef)>,
|
|
|
+ then_reply: bool,
|
|
|
+ ) {
|
|
|
+ // The MIME type of each audio comes from its download, so the shared job leaves it empty.
|
|
|
+ let Some(job) = self.config.transcribe_key.clone().map(|key| TranscriptionJob {
|
|
|
+ url: self.config.transcribe_url.clone(),
|
|
|
+ key,
|
|
|
+ model: self.config.transcribe_model.clone(),
|
|
|
+ language: self.config.transcribe_language.clone(),
|
|
|
+ mime: String::new(),
|
|
|
+ }) else {
|
|
|
+ return;
|
|
|
+ };
|
|
|
+ self.transcribing.extend(audios.iter().map(|(message_id, _)| message_id.clone()));
|
|
|
+ spawn_audio_transcriptions(
|
|
|
+ job,
|
|
|
+ self.wa.clone(),
|
|
|
+ self.events.clone(),
|
|
|
+ chat_jid.to_owned(),
|
|
|
+ then_reply,
|
|
|
+ audios,
|
|
|
+ images,
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Purpose: tell whether audio transcription is configured.
|
|
|
+ /// Inputs: none.
|
|
|
+ /// Output: `true` when the transcription API key is set; the URL, model and language always
|
|
|
+ /// have a value (defaults from the configuration).
|
|
|
+ /// Side effects: none.
|
|
|
+ fn transcription_enabled(&self) -> bool {
|
|
|
+ self.config.transcribe_key.is_some()
|
|
|
+ }
|
|
|
+
|
|
|
/// Purpose: spawn the description of images, tracking them so none is described twice.
|
|
|
/// Inputs: `ai`, the client; `chat_jid`, their chat; `images`, the images with the ids of
|
|
|
/// their messages; `then_reply`, whether the chat must be answered once they are described.
|
|
|
@@ -613,12 +769,71 @@ impl App {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ /// Purpose: store the transcriptions of a chat, then describe its postponed images and
|
|
|
+ /// answer it when requested.
|
|
|
+ /// Inputs: `chat_jid`, the chat; `then_reply`, whether it must be answered; `results`, the
|
|
|
+ /// transcription (or error text) of each audio, with the id of the message carrying it;
|
|
|
+ /// `images`, the images to describe once transcribed.
|
|
|
+ /// Output: `Ok(())`, or a history error.
|
|
|
+ /// Side effects: removes the ids from `transcribing`; appends each transcription to the text
|
|
|
+ /// of its message when it still exists (each audio is transcribed once); failures set one
|
|
|
+ /// status (see `transcription_failure_status`) and leave those audios without transcription.
|
|
|
+ /// Reloads the open chat so the text appears. Then, while the chat is still pending, describes
|
|
|
+ /// the postponed images (whose `ImagesDescribed` event sends the reply) or, with none to
|
|
|
+ /// describe, spawns the reply if it is still wanted, else ends the pending state.
|
|
|
+ fn handle_audios_transcribed(
|
|
|
+ &mut self,
|
|
|
+ chat_jid: &str,
|
|
|
+ then_reply: bool,
|
|
|
+ results: Vec<(String, Result<String, String>)>,
|
|
|
+ images: Vec<(String, ImageRef)>,
|
|
|
+ ) -> Result<()> {
|
|
|
+ let mut failures = Vec::new();
|
|
|
+ for (message_id, result) in results {
|
|
|
+ self.transcribing.remove(&message_id);
|
|
|
+ match result {
|
|
|
+ Ok(text) if self.history.message_exists(&message_id)? => {
|
|
|
+ self.history.update_message_text(&message_id, &format!("{VOICE_NOTE_TEXT} {text}"))?;
|
|
|
+ }
|
|
|
+ Ok(_) => {}
|
|
|
+ Err(err) => failures.push(err),
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if let Some(status) = transcription_failure_status(&failures) {
|
|
|
+ self.status = status;
|
|
|
+ }
|
|
|
+ if self.open_chat.as_deref() == Some(chat_jid) {
|
|
|
+ self.reload_messages()?;
|
|
|
+ }
|
|
|
+ if !images.is_empty() && self.gate.is_pending(chat_jid) {
|
|
|
+ let Some((ai, prompt, context)) = self.reply_context(chat_jid)? else {
|
|
|
+ return self.finish_reply(chat_jid);
|
|
|
+ };
|
|
|
+ let images = self.images_to_describe(chat_jid, &context, images, ai.has_vision())?;
|
|
|
+ if !images.is_empty() {
|
|
|
+ self.spawn_descriptions(ai, chat_jid, images, then_reply);
|
|
|
+ return Ok(());
|
|
|
+ }
|
|
|
+ if !then_reply {
|
|
|
+ return Ok(());
|
|
|
+ }
|
|
|
+ return self.start_reply(ai, &prompt, chat_jid, &context);
|
|
|
+ }
|
|
|
+ if !then_reply || !self.gate.is_pending(chat_jid) {
|
|
|
+ return Ok(());
|
|
|
+ }
|
|
|
+ match self.reply_context(chat_jid)? {
|
|
|
+ Some((ai, prompt, context)) => self.start_reply(ai, &prompt, chat_jid, &context),
|
|
|
+ None => self.finish_reply(chat_jid),
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
/// Purpose: end the pending state of a chat once its reply is done or dropped.
|
|
|
/// Inputs: `chat_jid`, the chat.
|
|
|
/// Output: `Ok(())`, or a history error.
|
|
|
/// Side effects: clears the pending state and the answered snapshot; if a reply was sent and
|
|
|
/// a newer message that deserves a reply arrived meanwhile (see `arrived_after`), schedules a
|
|
|
- /// new reply (keeping the images received meanwhile), otherwise drops those images.
|
|
|
+ /// new reply (keeping the images and audios received meanwhile), otherwise drops them.
|
|
|
fn finish_reply(&mut self, chat_jid: &str) -> Result<()> {
|
|
|
let answered = self.answered_up_to.remove(chat_jid);
|
|
|
// The returned flag only tells whether the chat was pending: nothing to do either way.
|
|
|
@@ -632,6 +847,7 @@ impl App {
|
|
|
return Ok(());
|
|
|
}
|
|
|
self.pending_images.remove(chat_jid);
|
|
|
+ self.pending_audios.remove(chat_jid);
|
|
|
Ok(())
|
|
|
}
|
|
|
|
|
|
@@ -666,7 +882,7 @@ impl App {
|
|
|
/// Purpose: pick the images of a chat that still need a description.
|
|
|
/// Inputs: `chat_jid`, the chat; `context`, its messages; `images`, the kept images in
|
|
|
/// arrival order; `has_vision`, whether a vision model is configured.
|
|
|
- /// Output: see `select_images_to_describe` (stored descriptions and images being described
|
|
|
+ /// Output: see `select_media_to_describe` (stored descriptions and images being described
|
|
|
/// are skipped); or a history error.
|
|
|
/// Side effects: reads the stored image descriptions.
|
|
|
fn images_to_describe(
|
|
|
@@ -678,7 +894,27 @@ impl App {
|
|
|
) -> Result<Vec<(String, ImageRef)>> {
|
|
|
let described = self.history.image_descriptions(chat_jid)?;
|
|
|
let known = |message_id: &str| described.contains_key(message_id) || self.describing.contains(message_id);
|
|
|
- Ok(select_images_to_describe(images, context, known, has_vision))
|
|
|
+ Ok(select_media_to_describe(images, context, known, has_vision, MAX_IMAGES_PER_REPLY))
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Purpose: pick the audios of a chat that still need a transcription.
|
|
|
+ /// Inputs: `context`, its messages; `audios`, the kept audios in arrival order.
|
|
|
+ /// Output: see `select_media_to_describe` (audios being transcribed and messages whose text
|
|
|
+ /// is no longer the plain voice placeholder are skipped), or an empty list when transcription
|
|
|
+ /// is not configured.
|
|
|
+ /// Side effects: none.
|
|
|
+ fn audios_to_transcribe(
|
|
|
+ &self,
|
|
|
+ context: &[StoredMessage],
|
|
|
+ audios: Vec<(String, AudioRef)>,
|
|
|
+ ) -> Vec<(String, AudioRef)> {
|
|
|
+ let known = |message_id: &str| {
|
|
|
+ self.transcribing.contains(message_id)
|
|
|
+ || context
|
|
|
+ .iter()
|
|
|
+ .any(|message| message.message_id == message_id && !is_voice_note(&message.text))
|
|
|
+ };
|
|
|
+ select_media_to_describe(audios, context, known, self.transcription_enabled(), MAX_AUDIOS_PER_REPLY)
|
|
|
}
|
|
|
|
|
|
/// Purpose: run the catch-up once per process when the offline messages have been delivered.
|
|
|
@@ -697,12 +933,13 @@ impl App {
|
|
|
/// reached the history while the application was off.
|
|
|
/// Inputs: none (the age limit is `catch_up_seconds` from the configuration).
|
|
|
/// Output: `Ok(())`, or a history error.
|
|
|
- /// Side effects: for every chat found by `catch_up_chats`, marks it pending, describes its
|
|
|
- /// offline images and answers at once (no reply delay, see `prepare_reply`); drops the other
|
|
|
- /// offline images; sets the status to the number of replies that went ahead, when above
|
|
|
- /// zero. Does nothing when the AI is not configured.
|
|
|
+ /// Side effects: for every chat found by `catch_up_chats`, marks it pending, transcribes its
|
|
|
+ /// offline audios, describes its offline images and answers at once (no reply delay, see
|
|
|
+ /// `prepare_reply`); drops the other offline media; sets the status to the number of replies
|
|
|
+ /// that went ahead, when above zero. Does nothing when the AI is not configured.
|
|
|
fn catch_up(&mut self) -> Result<()> {
|
|
|
- let mut backlog = std::mem::take(&mut self.backlog_images);
|
|
|
+ let mut backlog_images = std::mem::take(&mut self.backlog_images);
|
|
|
+ let mut backlog_audios = std::mem::take(&mut self.backlog_audios);
|
|
|
if self.ai_setup().is_none() {
|
|
|
return Ok(());
|
|
|
}
|
|
|
@@ -711,9 +948,10 @@ impl App {
|
|
|
}
|
|
|
let mut started = 0;
|
|
|
for chat_jid in self.catch_up_chats(now_unix())? {
|
|
|
- let images = backlog.remove(&chat_jid).unwrap_or_default();
|
|
|
+ let images = backlog_images.remove(&chat_jid).unwrap_or_default();
|
|
|
+ let audios = backlog_audios.remove(&chat_jid).unwrap_or_default();
|
|
|
self.gate.mark_pending(&chat_jid);
|
|
|
- if self.prepare_reply(&chat_jid, images)? {
|
|
|
+ if self.prepare_reply(&chat_jid, images, audios)? {
|
|
|
started += 1;
|
|
|
}
|
|
|
}
|
|
|
@@ -1011,8 +1249,9 @@ impl App {
|
|
|
/// Purpose: delete a chat from the local history only (no WhatsApp call).
|
|
|
/// Inputs: `jid`, the chat to delete; `name`, its display name for the status.
|
|
|
/// Output: `Ok(())`, or a history error.
|
|
|
- /// Side effects: deletes the chat from the history, drops its scheduled AI reply, closes it if
|
|
|
- /// open, reloads the chat list and updates `status`; always clears `pending_delete`.
|
|
|
+ /// Side effects: deletes the chat from the history, drops its scheduled AI reply and the
|
|
|
+ /// images and audios kept for it, closes it if open, reloads the chat list and updates
|
|
|
+ /// `status`; always clears `pending_delete`.
|
|
|
fn confirm_delete(&mut self, jid: String, name: String) -> Result<()> {
|
|
|
self.pending_delete = None;
|
|
|
self.history.delete_chat(&jid)?;
|
|
|
@@ -1021,6 +1260,8 @@ impl App {
|
|
|
let _ = self.gate.take_pending(&jid);
|
|
|
self.pending_images.remove(&jid);
|
|
|
self.backlog_images.remove(&jid);
|
|
|
+ self.pending_audios.remove(&jid);
|
|
|
+ self.backlog_audios.remove(&jid);
|
|
|
self.answered_up_to.remove(&jid);
|
|
|
if self.open_chat.as_deref() == Some(jid.as_str()) {
|
|
|
self.close_open_chat();
|
|
|
@@ -1403,31 +1644,33 @@ fn spawn_image_descriptions(
|
|
|
});
|
|
|
}
|
|
|
|
|
|
-/// Purpose: choose the images to describe before a reply.
|
|
|
-/// Inputs: `images`, the kept images in arrival order with the ids of their messages;
|
|
|
-/// `context`, the messages that will be sent to the AI; `known`, tells whether a message id
|
|
|
-/// already has a description or one in progress; `has_vision`, whether a vision model is set.
|
|
|
-/// Output: the images whose message is in `context` and not `known`, at most the last
|
|
|
-/// `MAX_IMAGES_PER_REPLY`, in arrival order; empty without a vision model.
|
|
|
+/// Purpose: choose the media to describe before a reply.
|
|
|
+/// Inputs: `items`, the kept media in arrival order with the ids of their messages; `context`,
|
|
|
+/// the messages that will be sent to the AI; `known`, tells whether a message id is already
|
|
|
+/// described or being described; `enabled`, whether the description is possible (a vision model
|
|
|
+/// for images, the transcriber for audios); `limit`, the maximum number of items to keep.
|
|
|
+/// Output: the items whose message is in `context` and not `known`, at most the last `limit`, in
|
|
|
+/// arrival order; empty when not `enabled`.
|
|
|
/// Side effects: none.
|
|
|
-fn select_images_to_describe<T>(
|
|
|
- images: Vec<(String, T)>,
|
|
|
+fn select_media_to_describe<T>(
|
|
|
+ items: Vec<(String, T)>,
|
|
|
context: &[StoredMessage],
|
|
|
known: impl Fn(&str) -> bool,
|
|
|
- has_vision: bool,
|
|
|
+ enabled: bool,
|
|
|
+ limit: usize,
|
|
|
) -> Vec<(String, T)> {
|
|
|
- if !vision_applies(!images.is_empty(), has_vision) {
|
|
|
+ if !descriptions_apply(!items.is_empty(), enabled) {
|
|
|
return Vec::new();
|
|
|
}
|
|
|
- let mut selected: Vec<(String, T)> = images
|
|
|
+ let mut selected: Vec<(String, T)> = items
|
|
|
.into_iter()
|
|
|
.filter(|(message_id, _)| !known(message_id))
|
|
|
.filter(|(message_id, _)| context.iter().any(|message| message.message_id == *message_id))
|
|
|
.collect();
|
|
|
|
|
|
- // Intentional: older images beyond the limit are dropped for good, never described later,
|
|
|
- // to cap the cost of albums and bursts of photos.
|
|
|
- let excess = selected.len().saturating_sub(MAX_IMAGES_PER_REPLY);
|
|
|
+ // Intentional: older items beyond the limit are dropped for good, never described later, to
|
|
|
+ // cap the cost of albums, bursts of photos and bursts of voice notes.
|
|
|
+ let excess = selected.len().saturating_sub(limit);
|
|
|
selected.drain(..excess);
|
|
|
selected
|
|
|
}
|
|
|
@@ -1464,6 +1707,16 @@ fn description_failure_status(failures: &[String]) -> Option<String> {
|
|
|
Some(format!("{STATUS_IMAGE_DESCRIPTION_PREFIX} ({}): {first}", failures.len()))
|
|
|
}
|
|
|
|
|
|
+/// Purpose: build the status for failed audio transcriptions.
|
|
|
+/// Inputs: `failures`, the error texts, in order.
|
|
|
+/// Output: `None` when empty, otherwise `STATUS_AUDIO_TRANSCRIPTION_PREFIX`, the number of
|
|
|
+/// failures in parentheses and the first error.
|
|
|
+/// Side effects: none.
|
|
|
+fn transcription_failure_status(failures: &[String]) -> Option<String> {
|
|
|
+ let first = failures.first()?;
|
|
|
+ Some(format!("{STATUS_AUDIO_TRANSCRIPTION_PREFIX} ({}): {first}", failures.len()))
|
|
|
+}
|
|
|
+
|
|
|
/// Purpose: download one image and ask the vision model for its description.
|
|
|
/// Inputs: `ai`, the client; `wa`, the WhatsApp handle; `image`, the reference of the image.
|
|
|
/// Output: the description, or the error text of the download or of the description request.
|
|
|
@@ -1478,37 +1731,129 @@ async fn describe_one_image(ai: &AiClient, wa: &WaHandle, image: &ImageRef) -> R
|
|
|
/// Inputs: `wa`, the WhatsApp handle; `image`, the reference of the image.
|
|
|
/// Output: the image bytes and MIME type, or the error text of the last attempt carrying
|
|
|
/// `STATUS_IMAGE_DOWNLOAD_PREFIX`.
|
|
|
-/// Side effects: up to `IMAGE_DOWNLOAD_ATTEMPTS` CDN downloads, each abandoned after
|
|
|
-/// `IMAGE_DOWNLOAD_TIMEOUT_SECONDS`, waiting `IMAGE_DOWNLOAD_RETRY_SECONDS` times the attempt
|
|
|
+/// Side effects: up to `MEDIA_DOWNLOAD_ATTEMPTS` CDN downloads, each abandoned after
|
|
|
+/// `MEDIA_DOWNLOAD_TIMEOUT_SECONDS`, waiting `MEDIA_DOWNLOAD_RETRY_SECONDS` times the attempt
|
|
|
/// number between them; the bytes stay in memory.
|
|
|
async fn download_image_with_retry(wa: &WaHandle, image: &ImageRef) -> Result<ImageInput, String> {
|
|
|
let mut attempt = 1;
|
|
|
loop {
|
|
|
- let timeout = Duration::from_secs(IMAGE_DOWNLOAD_TIMEOUT_SECONDS);
|
|
|
+ let timeout = Duration::from_secs(MEDIA_DOWNLOAD_TIMEOUT_SECONDS);
|
|
|
let outcome = match tokio::time::timeout(timeout, wa.download_image(image)).await {
|
|
|
Ok(result) => result.map_err(|err| format!("{err:#}")),
|
|
|
- Err(_) => Err(IMAGE_DOWNLOAD_TIMEOUT_MESSAGE.to_owned()),
|
|
|
+ Err(_) => Err(MEDIA_DOWNLOAD_TIMEOUT_MESSAGE.to_owned()),
|
|
|
};
|
|
|
match outcome {
|
|
|
Ok(downloaded) => return Ok(ImageInput { mime: downloaded.mime, bytes: downloaded.bytes }),
|
|
|
- Err(err) if attempt >= IMAGE_DOWNLOAD_ATTEMPTS => {
|
|
|
+ Err(err) if attempt >= MEDIA_DOWNLOAD_ATTEMPTS => {
|
|
|
return Err(format!("{STATUS_IMAGE_DOWNLOAD_PREFIX}: {err}"));
|
|
|
}
|
|
|
Err(_) => {
|
|
|
- tokio::time::sleep(Duration::from_secs(IMAGE_DOWNLOAD_RETRY_SECONDS * u64::from(attempt))).await;
|
|
|
+ tokio::time::sleep(Duration::from_secs(MEDIA_DOWNLOAD_RETRY_SECONDS * u64::from(attempt))).await;
|
|
|
+ attempt += 1;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/// Purpose: download one audio, retrying failed attempts.
|
|
|
+/// Inputs: `wa`, the WhatsApp handle; `audio`, the reference of the audio.
|
|
|
+/// Output: the audio bytes and MIME type, or the error text of the last attempt carrying
|
|
|
+/// `STATUS_AUDIO_DOWNLOAD_PREFIX`.
|
|
|
+/// Side effects: up to `MEDIA_DOWNLOAD_ATTEMPTS` CDN downloads, each abandoned after
|
|
|
+/// `MEDIA_DOWNLOAD_TIMEOUT_SECONDS`, waiting `MEDIA_DOWNLOAD_RETRY_SECONDS` times the attempt
|
|
|
+/// number between them; the bytes stay in memory.
|
|
|
+async fn download_audio_with_retry(wa: &WaHandle, audio: &AudioRef) -> Result<DownloadedAudio, String> {
|
|
|
+ let mut attempt = 1;
|
|
|
+ loop {
|
|
|
+ let timeout = Duration::from_secs(MEDIA_DOWNLOAD_TIMEOUT_SECONDS);
|
|
|
+ let outcome = match tokio::time::timeout(timeout, wa.download_audio(audio)).await {
|
|
|
+ Ok(result) => result.map_err(|err| format!("{err:#}")),
|
|
|
+ Err(_) => Err(MEDIA_DOWNLOAD_TIMEOUT_MESSAGE.to_owned()),
|
|
|
+ };
|
|
|
+ match outcome {
|
|
|
+ Ok(downloaded) => return Ok(downloaded),
|
|
|
+ Err(err) if attempt >= MEDIA_DOWNLOAD_ATTEMPTS => {
|
|
|
+ return Err(format!("{STATUS_AUDIO_DOWNLOAD_PREFIX}: {err}"));
|
|
|
+ }
|
|
|
+ Err(_) => {
|
|
|
+ tokio::time::sleep(Duration::from_secs(MEDIA_DOWNLOAD_RETRY_SECONDS * u64::from(attempt))).await;
|
|
|
attempt += 1;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-/// Purpose: decide whether the images of a reply are described.
|
|
|
-/// Inputs: `has_image`, whether there are images to describe; `has_vision`, whether the AI
|
|
|
-/// client has a vision model configured.
|
|
|
-/// Output: `true` only when both hold; otherwise the images stay without description.
|
|
|
+/// Purpose: transcribe audios in the background, once each.
|
|
|
+/// Inputs: `job`, the transcription parameters, whose MIME type is replaced by each download's;
|
|
|
+/// `wa`, the WhatsApp handle; `events`, the app event sender; `chat_jid`, their chat;
|
|
|
+/// `then_reply`, whether to answer it afterwards; `audios`, the audios with the ids of their
|
|
|
+/// messages; `images`, the images to describe once they are transcribed.
|
|
|
+/// Output: none.
|
|
|
+/// Side effects: spawns one tokio task per audio, all running in parallel, each downloading (see
|
|
|
+/// `download_audio_with_retry`) and transcribing its audio, plus one task
|
|
|
+/// that waits for all of them and sends a single `AudiosTranscribed` with one result per audio; a
|
|
|
+/// failed audio does not stop the others. The bytes are never logged or stored.
|
|
|
+fn spawn_audio_transcriptions(
|
|
|
+ job: TranscriptionJob,
|
|
|
+ wa: WaHandle,
|
|
|
+ events: UnboundedSender<AppEvent>,
|
|
|
+ chat_jid: String,
|
|
|
+ then_reply: bool,
|
|
|
+ audios: Vec<(String, AudioRef)>,
|
|
|
+ images: Vec<(String, ImageRef)>,
|
|
|
+) {
|
|
|
+ let tasks: Vec<_> = audios
|
|
|
+ .into_iter()
|
|
|
+ .map(|(message_id, audio)| {
|
|
|
+ let wa = wa.clone();
|
|
|
+ let job = TranscriptionJob {
|
|
|
+ url: job.url.clone(),
|
|
|
+ key: job.key.clone(),
|
|
|
+ model: job.model.clone(),
|
|
|
+ language: job.language.clone(),
|
|
|
+ mime: job.mime.clone(),
|
|
|
+ };
|
|
|
+ let task = tokio::spawn(async move { transcribe_one_audio(job, &wa, &audio).await });
|
|
|
+ (message_id, task)
|
|
|
+ })
|
|
|
+ .collect();
|
|
|
+ tokio::spawn(async move {
|
|
|
+ let mut results = Vec::with_capacity(tasks.len());
|
|
|
+ for (message_id, task) in tasks {
|
|
|
+ let result = task
|
|
|
+ .await
|
|
|
+ .unwrap_or_else(|err| Err(format!("{STATUS_AUDIO_TRANSCRIPTION_PREFIX}: {err}")));
|
|
|
+ results.push((message_id, result));
|
|
|
+ }
|
|
|
+ send_event(&events, AppEvent::AudiosTranscribed { chat_jid, then_reply, results, images });
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+/// Purpose: download one audio and transcribe it.
|
|
|
+/// Inputs: `job`, the transcription parameters, whose MIME type is replaced by the downloaded
|
|
|
+/// one; `wa`, the WhatsApp handle; `audio`, the reference of the audio.
|
|
|
+/// Output: the transcription, or the error text of the download or of the transcription service.
|
|
|
+/// Side effects: CDN download (see `download_audio_with_retry`) and one request to the
|
|
|
+/// transcription service; the bytes stay in memory only and are never logged.
|
|
|
+async fn transcribe_one_audio(
|
|
|
+ job: TranscriptionJob,
|
|
|
+ wa: &WaHandle,
|
|
|
+ audio: &AudioRef,
|
|
|
+) -> Result<String, String> {
|
|
|
+ let downloaded = download_audio_with_retry(wa, audio).await?;
|
|
|
+ let job = TranscriptionJob { mime: downloaded.mime, ..job };
|
|
|
+ crate::transcribe::transcribe(&job, &downloaded.bytes)
|
|
|
+ .await
|
|
|
+ .map_err(|err| format!("{err:#}"))
|
|
|
+}
|
|
|
+
|
|
|
+/// Purpose: decide whether the media of a reply are described.
|
|
|
+/// Inputs: `has_media`, whether there are items to describe; `enabled`, whether the description
|
|
|
+/// is possible (a vision model for images, the transcriber for audios).
|
|
|
+/// Output: `true` only when both hold; otherwise the media stay without description.
|
|
|
/// Side effects: none.
|
|
|
-fn vision_applies(has_image: bool, has_vision: bool) -> bool {
|
|
|
- has_image && has_vision
|
|
|
+fn descriptions_apply(has_media: bool, enabled: bool) -> bool {
|
|
|
+ has_media && enabled
|
|
|
}
|
|
|
|
|
|
/// Purpose: send a text message in the background.
|
|
|
@@ -1654,11 +1999,11 @@ mod tests {
|
|
|
}
|
|
|
|
|
|
#[test]
|
|
|
- fn vision_applies_requires_image_and_vision_model() {
|
|
|
- assert!(vision_applies(true, true));
|
|
|
- assert!(!vision_applies(true, false));
|
|
|
- assert!(!vision_applies(false, true));
|
|
|
- assert!(!vision_applies(false, false));
|
|
|
+ fn descriptions_apply_requires_media_and_the_needed_model() {
|
|
|
+ assert!(descriptions_apply(true, true));
|
|
|
+ assert!(!descriptions_apply(true, false));
|
|
|
+ assert!(!descriptions_apply(false, true));
|
|
|
+ assert!(!descriptions_apply(false, false));
|
|
|
}
|
|
|
|
|
|
/// Builds a stored message with the given id, for the tests.
|
|
|
@@ -1681,26 +2026,39 @@ mod tests {
|
|
|
}
|
|
|
|
|
|
#[test]
|
|
|
- fn select_images_keeps_only_the_last_five_in_arrival_order() {
|
|
|
+ fn select_media_keeps_only_the_last_five_images_in_arrival_order() {
|
|
|
let names = ["i1", "i2", "i3", "i4", "i5", "i6", "i7"];
|
|
|
let context: Vec<StoredMessage> = names.iter().map(|id| stored(id)).collect();
|
|
|
let images = names.iter().map(|id| ((*id).to_owned(), ())).collect();
|
|
|
- let selected = select_images_to_describe(images, &context, |_| false, true);
|
|
|
+ let selected = select_media_to_describe(images, &context, |_| false, true, MAX_IMAGES_PER_REPLY);
|
|
|
assert_eq!(ids(&selected), ["i3", "i4", "i5", "i6", "i7"]);
|
|
|
}
|
|
|
|
|
|
#[test]
|
|
|
- fn select_images_skips_known_and_out_of_context_images() {
|
|
|
+ fn select_media_keeps_only_the_last_three_audios_in_arrival_order() {
|
|
|
+ let names = ["a1", "a2", "a3", "a4", "a5"];
|
|
|
+ let context: Vec<StoredMessage> = names.iter().map(|id| stored(id)).collect();
|
|
|
+ let audios = names.iter().map(|id| ((*id).to_owned(), ())).collect();
|
|
|
+ let selected = select_media_to_describe(audios, &context, |_| false, true, MAX_AUDIOS_PER_REPLY);
|
|
|
+ assert_eq!(ids(&selected), ["a3", "a4", "a5"]);
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn select_media_skips_known_and_out_of_context_items() {
|
|
|
let context = [stored("described"), stored("new")];
|
|
|
let images = vec![("described".to_owned(), ()), ("new".to_owned(), ()), ("old".to_owned(), ())];
|
|
|
- let selected = select_images_to_describe(images, &context, |id| id == "described", true);
|
|
|
+ let selected =
|
|
|
+ select_media_to_describe(images, &context, |id| id == "described", true, MAX_IMAGES_PER_REPLY);
|
|
|
assert_eq!(ids(&selected), ["new"]);
|
|
|
}
|
|
|
|
|
|
#[test]
|
|
|
- fn select_images_is_empty_without_vision_model() {
|
|
|
+ fn select_media_is_empty_when_not_enabled() {
|
|
|
let context = [stored("a")];
|
|
|
- assert!(select_images_to_describe(vec![("a".to_owned(), ())], &context, |_| false, false).is_empty());
|
|
|
+ assert!(
|
|
|
+ select_media_to_describe(vec![("a".to_owned(), ())], &context, |_| false, false, MAX_IMAGES_PER_REPLY)
|
|
|
+ .is_empty()
|
|
|
+ );
|
|
|
}
|
|
|
|
|
|
#[test]
|
|
|
@@ -1736,6 +2094,20 @@ mod tests {
|
|
|
assert_eq!(two, Some(format!("{STATUS_IMAGE_DESCRIPTION_PREFIX} (2): timeout")));
|
|
|
}
|
|
|
|
|
|
+ #[test]
|
|
|
+ fn transcription_failure_status_counts_failures_and_shows_the_first() {
|
|
|
+ assert_eq!(transcription_failure_status(&[]), None);
|
|
|
+ let one = transcription_failure_status(&["timeout".to_owned()]);
|
|
|
+ assert_eq!(one, Some(format!("{STATUS_AUDIO_TRANSCRIPTION_PREFIX} (1): timeout")));
|
|
|
+ let two = transcription_failure_status(&["timeout".to_owned(), "uscita 1".to_owned()]);
|
|
|
+ assert_eq!(two, Some(format!("{STATUS_AUDIO_TRANSCRIPTION_PREFIX} (2): timeout")));
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn voice_note_text_is_a_plain_voice_placeholder() {
|
|
|
+ assert!(is_voice_note(VOICE_NOTE_TEXT));
|
|
|
+ }
|
|
|
+
|
|
|
#[test]
|
|
|
fn matches_filter_is_case_insensitive_and_empty_matches_all() {
|
|
|
assert!(matches_filter("Mario Rossi", "mar"));
|