From: Anderson A. Giacomolli Date: Wed, 11 Nov 2026 03:45:39 +0000 (-0300) Subject: Include the small model in the Nix package X-Git-Url: https://git.giacomolli.uk/onechat/commitdiff_plain/1a1e3835458e37b4b56897d6ff7804660798d60c?hp=d73a88b9e0835b1d7f0711e02db56555101a7830 Include the small model in the Nix package The Nix package supplies pinned small-model and Whisper executable paths for local voice transcription. The user instructions describe the local model and executable overrides for other builds. Chat media comments cover voice-note input, and voice test cases keep their fields explicit. --- diff --git a/README.rst b/README.rst index bbb4475..0148495 100644 --- a/README.rst +++ b/README.rst @@ -198,6 +198,13 @@ Images Its memory keeps what the image shows, and it can look at an older image again. Ask it to show an image or send one to a WhatsApp chat. +Voice notes + The agent hears Telegram and WhatsApp voice notes through local + Whisper. Nix includes the small model. Outside Nix, set + ``voice.model`` in ``config.json`` and install ``whisper-cli``. + ``voice.transcriber`` selects another executable. No audio goes to a + transcription service. + Schedules Ask for reminders and timed tasks, such as "every weekday at 8, tell me what my groups talked about". Each run uses the quota, so runs are diff --git a/nix/package.nix b/nix/package.nix index 41cd24f..c3fa562 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -3,13 +3,22 @@ buildGoModule, src, version, + callPackage, + whisper-cpp, }: +let + model = (callPackage ./voice-model.nix { }).small; +in buildGoModule { pname = "onechat"; inherit src version; subPackages = [ "." ]; - ldflags = [ "-X main.version=${version}" ]; + ldflags = [ + "-X main.version=${version}" + "-X main.voiceTranscriberPath=${whisper-cpp}/bin/whisper-cli" + "-X main.voiceModelPath=${model}" + ]; vendorHash = "sha256-nRDUAGSXAK5/L2xyfDh3NbMOZmrGGEIm4vtgfWsxHYU="; # The flake check runs the full replay. diff --git a/telegram.go b/telegram.go index 9494c0a..4ed0861 100644 --- a/telegram.go +++ b/telegram.go @@ -140,13 +140,13 @@ func (c TelegramConfig) validate() error { } // TelegramBot connects the private chat of the owner with the agent. It polls -// the Bot API for new messages and sends owner text and images to the agent. It -// ignores every other update. Its sender sends the replies, the errors, the -// notices of the bot, and the user messages of the other clients to the chat, -// and shows the typing action while a turn runs. Only the replies have a -// format: their Markdown goes as rich messages, which Telegram renders itself, -// and as message entities only when Telegram refuses a rich message. The rest -// is plain text. Tool calls do not go to the chat. +// the Bot API for new messages and sends owner text, images, and voice notes to +// the agent. It ignores every other update. Its sender sends the replies, the +// errors, the notices of the bot, and the user messages of the other clients to +// the chat, and shows the typing action while a turn runs. Only the replies +// have a format: their Markdown goes as rich messages, which Telegram renders +// itself, and as message entities only when Telegram refuses a rich message. +// The rest is plain text. Tool calls do not go to the chat. // // The offset file holds the ID of the next update. It moves past an update // only after the message of the update is durable in the log, so a restart diff --git a/telegram_api.go b/telegram_api.go index f53d8d5..6967c99 100644 --- a/telegram_api.go +++ b/telegram_api.go @@ -38,7 +38,8 @@ const ( // telegramFileLookupTimeout is the longest wait for media file details. telegramFileLookupTimeout = 30 * time.Second - // telegramFileDownloadTimeout is the longest wait for image bytes. + // telegramFileDownloadTimeout is the longest wait for downloaded media + // bytes. telegramFileDownloadTimeout = 30 * time.Second // telegramTypingTimeout is the longest wait to show the typing action. @@ -423,7 +424,7 @@ type TelegramAnswer struct { } `json:"parameters"` } -// TelegramMessage holds the text, caption, and image sizes of a bot message. +// TelegramMessage holds text and supported media metadata of a bot message. type TelegramMessage struct { Chat TelegramChat `json:"chat"` From *TelegramUser `json:"from"` diff --git a/voice_transcriber_test.go b/voice_transcriber_test.go index 48732f7..c9e24bc 100644 --- a/voice_transcriber_test.go +++ b/voice_transcriber_test.go @@ -57,9 +57,18 @@ func TestVoiceFailuresKeepStoredAudioAndSafeMarkers(t *testing.T) { name string script string }{ - {name: "failed process does not expose its errors", script: "echo secret-speech >&2; exit 1"}, - {name: "missing transcript gets a marker", script: "exit 0"}, - {name: "empty transcript gets a marker", script: "while [ \"$1\" != --output-file ]; do shift; done; shift; touch \"$1.txt\""}, + { + name: "failed process does not expose its errors", + script: "echo secret-speech >&2; exit 1", + }, + { + name: "missing transcript gets a marker", + script: "exit 0", + }, + { + name: "empty transcript gets a marker", + script: "while [ \"$1\" != --output-file ]; do shift; done; shift; touch \"$1.txt\"", + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { @@ -144,7 +153,10 @@ func TestVoiceConfigReadsExplicitLocalPaths(t *testing.T) { config, err := loadConfig(dir) require.NoError(t, err) - require.Equal(t, VoiceConfig{Transcriber: "local-whisper", Model: "model.bin"}, config.Voice) + require.Equal(t, VoiceConfig{ + Transcriber: "local-whisper", + Model: "model.bin", + }, config.Voice) } func TestVoiceLookupHonorsExplicitThenNixThenPath(t *testing.T) { @@ -178,8 +190,11 @@ func TestVoiceLookupHonorsExplicitThenNixThenPath(t *testing.T) { available: true, }, { - name: "invalid explicit executable does not fall back", - config: VoiceConfig{Transcriber: "missing", Model: valid.Model}, + name: "invalid explicit executable does not fall back", + config: VoiceConfig{ + Transcriber: "missing", + Model: valid.Model, + }, program: valid.Transcriber, }, { diff --git a/whatsapp_inbox.go b/whatsapp_inbox.go index 7d4e9e4..c9ca81b 100644 --- a/whatsapp_inbox.go +++ b/whatsapp_inbox.go @@ -34,7 +34,8 @@ const ( // newWhatsAppInbox returns the inbox of the batches in state, with clock for // the times of the batch texts and groups for the names of the groups. images -// holds shared images, and logger gets image failures. +// holds shared images, and logger gets image failures. Audio shares the data +// directory of state. func newWhatsAppInbox(state *WhatsAppState, clock func() time.Time, groups *WhatsAppGroups, images *ImageStore, logger *slog.Logger) *WhatsAppInbox { return &WhatsAppInbox{ state: state, @@ -229,10 +230,10 @@ func (i *WhatsAppInbox) startCatchup() { }) } -// receive stores an image before its batch and wakes Run. link gives the device -// and the own IDs. It returns false when the state file cannot hold the -// message, so that the library does not acknowledge it, and Run stops the -// daemon. An image that cannot be downloaded or stored gets a text marker. A +// receive stores images and transcribed voice notes before their batches and +// wakes Run. link gives the device and the own IDs. It returns false when the +// state file cannot hold the message, so that the library does not acknowledge +// it, and Run stops the daemon. Failed media input gets a text marker. A // message that the batches do not take is handled with nothing to do. func (i *WhatsAppInbox) receive(ctx context.Context, link WhatsAppLink, event *events.Message) bool { lookupCtx, cancel := context.WithTimeout(ctx, whatsappContactLookupTimeout) @@ -374,7 +375,8 @@ func whatsappMessage(ctx context.Context, device *store.Device, own []string, ev } // whatsappText returns the chat and the batch message of event, a message that -// the batches take. An image returns its caption until receive stores it. +// the batches take. An image returns its caption until receive stores it. A +// voice note returns a marker until receive transcribes it. func whatsappText(ctx context.Context, device *store.Device, own []string, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) { info := event.Info text := event.Message.GetConversation()