From d00929ac0b22e61824921336ef3cb846e5c86d76 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 14:02:57 +0400 Subject: [PATCH 1/3] Answer the day the user asked about and the city he named (#388) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit replySystem had two arms that PR 30 made reachable, and both answered confidently wrong: the date arm keyword-matched "числ" and always answered today, so "какое число завтра" answered today; the clock arm ignored a named city and answered local time. The date arm now reads the day word through router.ParseCalendarDate (which grew послезавтра/вчера and now cuts the day boundary in the local zone instead of UTC). The clock arm answers the named zone when it resolves offline from the tz database embedded in the binary, and otherwise says plainly that she only knows local time. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ --- cmd/mavend/system_reply_test.go | 58 ++++++++++++ cmd/mavend/voice.go | 146 ++++++++++++++++++++++++++++--- internal/router/slots.go | 30 +++++-- internal/router/slots_ru_test.go | 3 + 4 files changed, 219 insertions(+), 18 deletions(-) create mode 100644 cmd/mavend/system_reply_test.go diff --git a/cmd/mavend/system_reply_test.go b/cmd/mavend/system_reply_test.go new file mode 100644 index 0000000..1e8732e --- /dev/null +++ b/cmd/mavend/system_reply_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "context" + "testing" + "time" + + "github.com/kami/maven/internal/router" +) + +// systemHandler — a handler with nothing but a fixed clock, which is all +// replySystem needs. +func systemHandler(now time.Time) *reactiveHandler { + return &reactiveHandler{now: func() time.Time { return now }} +} + +// TestReplySystemDateOffset — "какое число завтра" must answer tomorrow's +// date, not today's (Vikunja #388). +func TestReplySystemDateOffset(t *testing.T) { + // Thursday, 30 July 2026. + now := time.Date(2026, 7, 30, 14, 5, 0, 0, time.UTC) + h := systemHandler(now) + cases := []struct{ utterance, want string }{ + {"какое сегодня число", "сегодня четверг, 30 июля 2026 года"}, + {"какое число", "сегодня четверг, 30 июля 2026 года"}, + {"какое число завтра", "завтра пятница, 31 июля 2026 года"}, + {"какое число послезавтра", "послезавтра суббота, 1 августа 2026 года"}, + {"какое было число вчера", "вчера среда, 29 июля 2026 года"}, + } + for _, c := range cases { + got := h.replySystem(context.Background(), router.Decision{Utterance: c.utterance}) + if got != c.want { + t.Errorf("replySystem(%q) = %q, want %q", c.utterance, got, c.want) + } + } +} + +// TestReplySystemClockCity — the clock arm must not answer local time for a +// question about another city (Vikunja #388). Known cities get their own zone; +// unknown places get an honest "local time only". +func TestReplySystemClockCity(t *testing.T) { + // 12:00 UTC — Kyiv is +03 in July, Moscow +03, London +01. + now := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC) + h := systemHandler(now) + cases := []struct{ utterance, want string }{ + {"который час", "сейчас 12 часов ровно"}, + {"который час в киеве", "в Киеве сейчас 15 часов ровно"}, + {"сколько времени в москве", "в Москве сейчас 15 часов ровно"}, + {"который час в лондоне", "в Лондоне сейчас 13 часов ровно"}, + {"который час в бишкеке", onlyLocalTimeReply}, + } + for _, c := range cases { + got := h.replySystem(context.Background(), router.Decision{Utterance: c.utterance}) + if got != c.want { + t.Errorf("replySystem(%q) = %q, want %q", c.utterance, got, c.want) + } + } +} diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index f1e0355..249fba6 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -55,6 +55,9 @@ import ( "strings" "sync" "time" + // Embeds the tz database in the binary so time.LoadLocation works even in + // a container image without /usr/share/zoneinfo. Stdlib, offline. + _ "time/tzdata" hexisclient "github.com/kami/hexis/pkg/client" "github.com/kami/maven/internal/audio" @@ -936,6 +939,115 @@ var ruMonths = []string{ "июля", "августа", "сентября", "октября", "ноября", "декабря", } +// onlyLocalTimeReply — the honest answer when the user names a place whose +// time zone we cannot resolve offline. Better than confidently naming the +// wrong city's time. +const onlyLocalTimeReply = "я знаю только местное время, про другие города пока не скажу." + +// cityZone — a city we can answer the clock for: its IANA time zone (resolved +// from the tzdata built into the binary, never over the network) and its +// Russian name in the "в ..." case. +type cityZone struct { + zone string + prepositional string +} + +// cityZones maps a lowercase city stem to its zone. Stems, not full words, so +// "в москве" / "москва" both hit. Keep in sync-ish with the weather city list. +var cityZones = map[string]cityZone{ + "москв": {"Europe/Moscow", "Москве"}, + "moscow": {"Europe/Moscow", "Москве"}, + "питер": {"Europe/Moscow", "Питере"}, + "петербур": {"Europe/Moscow", "Петербурге"}, + "киев": {"Europe/Kyiv", "Киеве"}, + "kyiv": {"Europe/Kyiv", "Киеве"}, + "kiev": {"Europe/Kyiv", "Киеве"}, + "минск": {"Europe/Minsk", "Минске"}, + "лондон": {"Europe/London", "Лондоне"}, + "london": {"Europe/London", "Лондоне"}, + "париж": {"Europe/Paris", "Париже"}, + "paris": {"Europe/Paris", "Париже"}, + "берлин": {"Europe/Berlin", "Берлине"}, + "berlin": {"Europe/Berlin", "Берлине"}, + "нью-йорк": {"America/New_York", "Нью-Йорке"}, + "new york": {"America/New_York", "Нью-Йорке"}, + "токио": {"Asia/Tokyo", "Токио"}, + "tokyo": {"Asia/Tokyo", "Токио"}, + "тбилиси": {"Asia/Tbilisi", "Тбилиси"}, + "екатеринбург": {"Asia/Yekaterinburg", "Екатеринбурге"}, + "новосибирск": {"Asia/Novosibirsk", "Новосибирске"}, + "владивосток": {"Asia/Vladivostok", "Владивостоке"}, +} + +// lookupCityZone finds a known city named in the utterance. +func lookupCityZone(u string) (cityZone, bool) { + for stem, cz := range cityZones { + if strings.Contains(u, stem) { + return cz, true + } + } + return cityZone{}, false +} + +// notPlaceAfterV — words that follow "в" without naming a place, so +// mentionsUnknownPlace does not mistake them for a city. +var notPlaceAfterV = map[string]bool{ + "данный": true, "данную": true, "этот": true, "эту": true, + "котором": true, "какое": true, "какой": true, "который": true, + "общем": true, "точности": true, "курсе": true, "сутках": true, + "часах": true, "минутах": true, "секундах": true, "неделе": true, +} + +// mentionsUnknownPlace reports whether the question has a "в <слово>" phrase +// that looks like a place we do not know ("который час в киеве"). Used only to +// pick the honest "local time only" reply instead of answering local time as +// if it were the city's. +func mentionsUnknownPlace(u string) bool { + toks := strings.Fields(u) + for i := 0; i+1 < len(toks); i++ { + if toks[i] != "в" && toks[i] != "во" { + continue + } + next := strings.Trim(toks[i+1], ".,?!") + if next == "" || notPlaceAfterV[next] { + continue + } + // A number after "в" is a clock ("в 5 часов"), not a place. + if _, err := strconv.Atoi(strings.SplitN(next, ":", 2)[0]); err == nil { + continue + } + return true + } + return false +} + +// ruClock renders the clock part of the time reply: "15 часов 4 минуты". +func ruClock(t time.Time) string { + h, m := t.Hour(), t.Minute() + hourWord := ruPlural(h, "час", "часа", "часов") + if m == 0 { + return fmt.Sprintf("%d %s ровно", h, hourWord) + } + return fmt.Sprintf("%d %s %d %s", h, hourWord, m, ruPlural(m, "минута", "минуты", "минут")) +} + +// dayPrefix names the day relative to now ("завтра", "вчера", …) so the date +// reply opens the way a person would say it. +func dayPrefix(now, day time.Time) string { + base := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + switch int(day.Sub(base).Hours() / 24) { + case -1: + return "вчера" + case 0: + return "сегодня" + case 1: + return "завтра" + case 2: + return "послезавтра" + } + return "это" +} + func ruPlural(n int, one, two, many string) string { n = n % 100 if n > 10 && n < 20 { @@ -1014,18 +1126,32 @@ func (h *reactiveHandler) replySystem(ctx context.Context, dec router.Decision) switch { case strings.Contains(u, "час") || strings.Contains(u, "врем"): - h := now.Hour() - m := now.Minute() - hourWord := ruPlural(h, "час", "часа", "часов") - if m == 0 { - return fmt.Sprintf("сейчас %d %s ровно", h, hourWord) + // "который час в киеве" — answer for the named city when we know its + // time zone locally, never guess. Unknown place: say so plainly. + if city, ok := lookupCityZone(u); ok { + loc, err := time.LoadLocation(city.zone) + if err != nil { + log.Printf("voice: load zone %s: %v", city.zone, err) + return onlyLocalTimeReply + } + return fmt.Sprintf("в %s сейчас %s", city.prepositional, ruClock(now.In(loc))) } - minWord := ruPlural(m, "минута", "минуты", "минут") - return fmt.Sprintf("сейчас %d %s %d %s", h, hourWord, m, minWord) + if mentionsUnknownPlace(u) { + return onlyLocalTimeReply + } + return "сейчас " + ruClock(now) case strings.Contains(u, "день") || strings.Contains(u, "числ"): - dow := ruWeekdays[now.Weekday()] - month := ruMonths[now.Month()-1] - return fmt.Sprintf("сегодня %s, %d %s %d года", dow, now.Day(), month, now.Year()) + // "какое число завтра" — answer for the day the user asked about, + // not today. Reuses the router's calendar day-word parser. + day := now + prefix := "сегодня" + if d, ok := router.ParseCalendarDate(u, now); ok { + day = d + prefix = dayPrefix(now, d) + } + dow := ruWeekdays[day.Weekday()] + month := ruMonths[day.Month()-1] + return fmt.Sprintf("%s %s, %d %s %d года", prefix, dow, day.Day(), month, day.Year()) case strings.Contains(u, "кто дома") || strings.Contains(u, "человек дома"): return "присутствие пока не подключено к голосовому запросу." case strings.Contains(u, "памят") || strings.Contains(u, "процессор") || strings.Contains(u, "загрузк") || strings.Contains(u, "статус") || strings.Contains(u, "работа") || strings.Contains(u, "сервис") || strings.Contains(u, "диск") || strings.Contains(u, "ip") || strings.Contains(u, "аптайм") || strings.Contains(u, "трафик") || strings.Contains(u, "интернет"): diff --git a/internal/router/slots.go b/internal/router/slots.go index ea579f5..95998a2 100644 --- a/internal/router/slots.go +++ b/internal/router/slots.go @@ -449,16 +449,30 @@ func (AnaphoraResolver) Resolve(text string) (ref string, ok bool) { return "", false } -// ParseCalendarDate detects RU calendar date words in text and returns the -// resolved time (midnight UTC+0 for "сегодня"/"today", next day for "завтра"/"tomorrow"). -// Returns zero time + false if no match. +// ParseCalendarDate detects RU/EN calendar day words in text and returns +// midnight of that day in now's own time zone. Handles "сегодня", "завтра", +// "послезавтра", "вчера" (and the English words). Returns zero time + false +// if no match. +// +// "послезавтра" is checked before "завтра" because it contains it. func ParseCalendarDate(text string, now time.Time) (time.Time, bool) { lower := strings.ToLower(text) - if strings.Contains(lower, "сегодня") || strings.Contains(lower, "today") { - return now.Truncate(24 * time.Hour), true - } - if strings.Contains(lower, "завтра") || strings.Contains(lower, "tomorrow") { - return now.Truncate(24 * time.Hour).Add(24 * time.Hour), true + switch { + case strings.Contains(lower, "сегодня") || strings.Contains(lower, "today"): + return midnight(now, 0), true + case strings.Contains(lower, "послезавтра") || strings.Contains(lower, "day after tomorrow"): + return midnight(now, 2), true + case strings.Contains(lower, "завтра") || strings.Contains(lower, "tomorrow"): + return midnight(now, 1), true + case strings.Contains(lower, "вчера") || strings.Contains(lower, "yesterday"): + return midnight(now, -1), true } return time.Time{}, false } + +// midnight returns the start of the day that is `days` away from now, in +// now's time zone (now.Truncate(24h) would cut on a UTC boundary instead). +func midnight(now time.Time, days int) time.Time { + y, m, d := now.AddDate(0, 0, days).Date() + return time.Date(y, m, d, 0, 0, 0, 0, now.Location()) +} diff --git a/internal/router/slots_ru_test.go b/internal/router/slots_ru_test.go index 4c52d37..f3e9f85 100644 --- a/internal/router/slots_ru_test.go +++ b/internal/router/slots_ru_test.go @@ -45,6 +45,9 @@ func TestParseCalendarDate(t *testing.T) { {"расписание на завтра", time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC), true}, {"what's today", time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC), true}, {"tomorrow plans", time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC), true}, + {"какое число послезавтра", time.Date(2026, 7, 8, 0, 0, 0, 0, time.UTC), true}, + {"что было вчера", time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC), true}, + {"yesterday plans", time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC), true}, {"какая погода", time.Time{}, false}, {"сколько времени", time.Time{}, false}, {"", time.Time{}, false}, -- 2.52.0 From 84ba217892d90138b3044b2c5a68f3f92681ac88 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 14:05:21 +0400 Subject: [PATCH 2/3] Say so when the day asked about is out of reach --- cmd/mavend/system_reply_test.go | 21 +++++++++++++++++++ cmd/mavend/voice.go | 36 ++++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/cmd/mavend/system_reply_test.go b/cmd/mavend/system_reply_test.go index 1e8732e..d04fe54 100644 --- a/cmd/mavend/system_reply_test.go +++ b/cmd/mavend/system_reply_test.go @@ -35,6 +35,27 @@ func TestReplySystemDateOffset(t *testing.T) { } } +// A day she cannot work out must not come back as today's date — that is the +// same silent wrong answer #388 was about, one step further out. +func TestReplySystemUnknownDayIsHonest(t *testing.T) { + now := time.Date(2026, 7, 30, 14, 5, 0, 0, time.UTC) + h := systemHandler(now) + for _, u := range []string{ + "какое число в пятницу", + "какое число через неделю", + "какое число в понедельник", + } { + got := h.replySystem(context.Background(), router.Decision{Utterance: u}) + if got != onlyNearDaysReply { + t.Errorf("replySystem(%q) = %q, want the honest reply", u, got) + } + } + // The days she does know must not be caught by the same guard. + if got := h.replySystem(context.Background(), router.Decision{Utterance: "какое число завтра"}); got == onlyNearDaysReply { + t.Error("завтра was treated as an unknown day") + } +} + // TestReplySystemClockCity — the clock arm must not answer local time for a // question about another city (Vikunja #388). Known cities get their own zone; // unknown places get an honest "local time only". diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 249fba6..feb0414 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -755,7 +755,9 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision) } // Calendar questions: "что у меня сегодня?", "планы на завтра?" - if date, ok := router.ParseCalendarDate(dec.Utterance, time.Now()); ok { + // h.now(), not time.Now(): the handler's clock is the injected one, so + // this arm can be tested at a fixed time like the rest. + if date, ok := router.ParseCalendarDate(dec.Utterance, h.now()); ok { events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour)) if err != nil { log.Printf("voice: calendar events: %v", err) @@ -1021,6 +1023,33 @@ func mentionsUnknownPlace(u string) bool { return false } +// onlyNearDaysReply — she can work out today, tomorrow, the day after and +// yesterday, and nothing further. Said out loud instead of answering today's +// date for a day she did not understand. +const onlyNearDaysReply = "я считаю только сегодня, завтра, послезавтра и вчера — про другие дни пока не скажу." + +// dayWords — day references the calendar parser cannot resolve. A weekday name +// or a "через …" phrase means he asked about a specific other day. +var dayWords = []string{ + "понедельник", "вторник", "сред", "четверг", "пятниц", "суббот", "воскресен", + "через", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", +} + +// mentionsUnknownDay reports whether the question names a day the calendar +// parser could not resolve. Mirror of mentionsUnknownPlace: it exists only to +// pick an honest reply over a confidently wrong one. +// +// Only called after ParseCalendarDate has already failed, so "завтра" and the +// other words it does know never reach here. +func mentionsUnknownDay(u string) bool { + for _, w := range dayWords { + if strings.Contains(u, w) { + return true + } + } + return false +} + // ruClock renders the clock part of the time reply: "15 часов 4 минуты". func ruClock(t time.Time) string { h, m := t.Hour(), t.Minute() @@ -1148,6 +1177,11 @@ func (h *reactiveHandler) replySystem(ctx context.Context, dec router.Decision) if d, ok := router.ParseCalendarDate(u, now); ok { day = d prefix = dayPrefix(now, d) + } else if mentionsUnknownDay(u) { + // He named a day she cannot work out ("в пятницу", "через неделю"). + // Answering today's date here would be the same silent wrong answer + // this arm was fixed for, so say what she can do instead. + return onlyNearDaysReply } dow := ruWeekdays[day.Weekday()] month := ruMonths[day.Month()-1] -- 2.52.0 From 3dbf67f8f95142f324fec3606ac630117b5de62e Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 14:14:18 +0400 Subject: [PATCH 3/3] Drop the city time-zone table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user only ever asks the time in his own zone, so answering other cities was code kept in step with the weather city list for no gain. Any named place now gets the honest "local time only" answer that was already there for unknown cities. Removes the 22-entry table, the lookup and the embedded tz database. Closes Vikunja #389 — there is only one city list again. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ --- cmd/mavend/system_reply_test.go | 11 +++--- cmd/mavend/voice.go | 70 +++++---------------------------- 2 files changed, 14 insertions(+), 67 deletions(-) diff --git a/cmd/mavend/system_reply_test.go b/cmd/mavend/system_reply_test.go index d04fe54..cfc06d4 100644 --- a/cmd/mavend/system_reply_test.go +++ b/cmd/mavend/system_reply_test.go @@ -57,17 +57,16 @@ func TestReplySystemUnknownDayIsHonest(t *testing.T) { } // TestReplySystemClockCity — the clock arm must not answer local time for a -// question about another city (Vikunja #388). Known cities get their own zone; -// unknown places get an honest "local time only". +// question about another city (Vikunja #388). She keeps one clock, so every +// named place gets the honest "local time only" answer. func TestReplySystemClockCity(t *testing.T) { - // 12:00 UTC — Kyiv is +03 in July, Moscow +03, London +01. now := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC) h := systemHandler(now) cases := []struct{ utterance, want string }{ {"который час", "сейчас 12 часов ровно"}, - {"который час в киеве", "в Киеве сейчас 15 часов ровно"}, - {"сколько времени в москве", "в Москве сейчас 15 часов ровно"}, - {"который час в лондоне", "в Лондоне сейчас 13 часов ровно"}, + {"который час в киеве", onlyLocalTimeReply}, + {"сколько времени в москве", onlyLocalTimeReply}, + {"который час в лондоне", onlyLocalTimeReply}, {"который час в бишкеке", onlyLocalTimeReply}, } for _, c := range cases { diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index feb0414..f736b73 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -55,9 +55,6 @@ import ( "strings" "sync" "time" - // Embeds the tz database in the binary so time.LoadLocation works even in - // a container image without /usr/share/zoneinfo. Stdlib, offline. - _ "time/tzdata" hexisclient "github.com/kami/hexis/pkg/client" "github.com/kami/maven/internal/audio" @@ -941,56 +938,15 @@ var ruMonths = []string{ "июля", "августа", "сентября", "октября", "ноября", "декабря", } -// onlyLocalTimeReply — the honest answer when the user names a place whose -// time zone we cannot resolve offline. Better than confidently naming the -// wrong city's time. +// onlyLocalTimeReply — the honest answer when the user asks the time somewhere +// other than here. She only keeps one clock, and saying so is better than +// naming the wrong city's time. +// +// There used to be a city→time-zone table here. It was removed on purpose: the +// user only ever asks for local time, so the table was a second list of cities +// to keep in step with the weather one for no gain. const onlyLocalTimeReply = "я знаю только местное время, про другие города пока не скажу." -// cityZone — a city we can answer the clock for: its IANA time zone (resolved -// from the tzdata built into the binary, never over the network) and its -// Russian name in the "в ..." case. -type cityZone struct { - zone string - prepositional string -} - -// cityZones maps a lowercase city stem to its zone. Stems, not full words, so -// "в москве" / "москва" both hit. Keep in sync-ish with the weather city list. -var cityZones = map[string]cityZone{ - "москв": {"Europe/Moscow", "Москве"}, - "moscow": {"Europe/Moscow", "Москве"}, - "питер": {"Europe/Moscow", "Питере"}, - "петербур": {"Europe/Moscow", "Петербурге"}, - "киев": {"Europe/Kyiv", "Киеве"}, - "kyiv": {"Europe/Kyiv", "Киеве"}, - "kiev": {"Europe/Kyiv", "Киеве"}, - "минск": {"Europe/Minsk", "Минске"}, - "лондон": {"Europe/London", "Лондоне"}, - "london": {"Europe/London", "Лондоне"}, - "париж": {"Europe/Paris", "Париже"}, - "paris": {"Europe/Paris", "Париже"}, - "берлин": {"Europe/Berlin", "Берлине"}, - "berlin": {"Europe/Berlin", "Берлине"}, - "нью-йорк": {"America/New_York", "Нью-Йорке"}, - "new york": {"America/New_York", "Нью-Йорке"}, - "токио": {"Asia/Tokyo", "Токио"}, - "tokyo": {"Asia/Tokyo", "Токио"}, - "тбилиси": {"Asia/Tbilisi", "Тбилиси"}, - "екатеринбург": {"Asia/Yekaterinburg", "Екатеринбурге"}, - "новосибирск": {"Asia/Novosibirsk", "Новосибирске"}, - "владивосток": {"Asia/Vladivostok", "Владивостоке"}, -} - -// lookupCityZone finds a known city named in the utterance. -func lookupCityZone(u string) (cityZone, bool) { - for stem, cz := range cityZones { - if strings.Contains(u, stem) { - return cz, true - } - } - return cityZone{}, false -} - // notPlaceAfterV — words that follow "в" without naming a place, so // mentionsUnknownPlace does not mistake them for a city. var notPlaceAfterV = map[string]bool{ @@ -1155,16 +1111,8 @@ func (h *reactiveHandler) replySystem(ctx context.Context, dec router.Decision) switch { case strings.Contains(u, "час") || strings.Contains(u, "врем"): - // "который час в киеве" — answer for the named city when we know its - // time zone locally, never guess. Unknown place: say so plainly. - if city, ok := lookupCityZone(u); ok { - loc, err := time.LoadLocation(city.zone) - if err != nil { - log.Printf("voice: load zone %s: %v", city.zone, err) - return onlyLocalTimeReply - } - return fmt.Sprintf("в %s сейчас %s", city.prepositional, ruClock(now.In(loc))) - } + // "который час в киеве" — she keeps one clock, so any named place gets + // the honest answer. Never local time dressed up as the city's. if mentionsUnknownPlace(u) { return onlyLocalTimeReply } -- 2.52.0