feat: ping on-duty members by Telegram handle in reminders

Read each member's Telegram handle from the new sheet column F (duty
count moves to G) and mention on-duty people as @handle in the weekly
reminder, so they get a real notification. Names without a handle, or
not found in the people list, fall back to plain text.

- sheet.go: add colHandle, Person.Handle; parse handle column
- scheduler.go: resolve names to @mentions via handlesByName/mention
- service.go: widen default SHEET_RANGE to column G
This commit is contained in:
Kamil 'Kamaś' Bruchal 2026-06-19 01:40:20 +02:00
parent 74cb302f64
commit 15b2fc9093
6 changed files with 111 additions and 17 deletions

View File

@ -94,12 +94,14 @@ func reminderText(rota Rota, target time.Time) (string, bool) {
return "", false return "", false
} }
handles := handlesByName(rota.People)
var people []string var people []string
if week.Person1 != "" { if week.Person1 != "" {
people = append(people, week.Person1) people = append(people, mention(week.Person1, handles))
} }
if week.Person2 != "" { if week.Person2 != "" {
people = append(people, week.Person2) people = append(people, mention(week.Person2, handles))
} }
var body string var body string
@ -116,6 +118,28 @@ func reminderText(rota Rota, target time.Time) (string, bool) {
week.WeekStart.Format("2006-01-02") + " " + body + ".", true week.WeekStart.Format("2006-01-02") + " " + body + ".", true
} }
// handlesByName indexes the roster's Telegram handles by person name, so a
// duty week's names can be resolved to mentions.
func handlesByName(people []Person) map[string]string {
m := make(map[string]string, len(people))
for _, p := range people {
m[strings.TrimSpace(p.Name)] = p.Handle
}
return m
}
// mention renders a duty name as a Telegram mention (@handle) when a handle is
// known, falling back to the plain name otherwise. The handle is normalized to
// carry exactly one leading "@"; Telegram auto-links and notifies chat members
// whose public username matches.
func mention(name string, handles map[string]string) string {
h := strings.TrimSpace(handles[strings.TrimSpace(name)])
if h == "" {
return name
}
return "@" + strings.TrimPrefix(h, "@")
}
// parseWeekday maps an English weekday name (case-insensitive) to time.Weekday. // parseWeekday maps an English weekday name (case-insensitive) to time.Weekday.
func parseWeekday(s string) (time.Weekday, error) { func parseWeekday(s string) (time.Weekday, error) {
days := map[string]time.Weekday{ days := map[string]time.Weekday{

View File

@ -130,6 +130,56 @@ func TestReminderText(t *testing.T) {
} }
} }
func TestReminderTextMentions(t *testing.T) {
target := day(t, "2026-06-30")
people := []Person{
{Name: "Ala", Handle: "@ala"}, // stored with leading @
{Name: "Bartek", Handle: "bart"}, // stored without @ -> normalized
{Name: "Cela", Handle: ""}, // present but no handle -> plain name
}
rota := func(p1, p2 string) Rota {
return Rota{Weeks: []DutyWeek{week(t, "2026-06-29", p1, p2)}, People: people}
}
tests := []struct {
name string
rota Rota
want string
}{
{
"both have handles",
rota("Ala", "Bartek"),
"Przypomnienie: w tygodniu od 2026-06-29 dyżur sprzątają @ala i @bart.",
},
{
"one handle, one missing -> mixed",
rota("Ala", "Cela"),
"Przypomnienie: w tygodniu od 2026-06-29 dyżur sprzątają @ala i Cela.",
},
{
"name not in people list -> plain name",
rota("Ala", "Zenon"),
"Przypomnienie: w tygodniu od 2026-06-29 dyżur sprzątają @ala i Zenon.",
},
{
"single person with handle",
rota("Bartek", ""),
"Przypomnienie: w tygodniu od 2026-06-29 dyżur sprząta @bart.",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, ok := reminderText(tc.rota, target)
if !ok {
t.Fatalf("ok = false, want true")
}
if got != tc.want {
t.Errorf("reminderText = %q, want %q", got, tc.want)
}
})
}
}
func TestParseWeekday(t *testing.T) { func TestParseWeekday(t *testing.T) {
tests := []struct { tests := []struct {
in string in string

View File

@ -32,7 +32,7 @@ func LoadConfig() (Config, error) {
// Default range targets the current year's tab (sheets are named per year, // Default range targets the current year's tab (sheets are named per year,
// e.g. "2026"), matching the spreadsheet layout. // e.g. "2026"), matching the spreadsheet layout.
defaultRange := fmt.Sprintf("%d!A1:F100", time.Now().Year()) defaultRange := fmt.Sprintf("%d!A1:G100", time.Now().Year())
cfg := Config{ cfg := Config{
SpreadsheetID: os.Getenv("GOOGLE_SHEETS_ID"), SpreadsheetID: os.Getenv("GOOGLE_SHEETS_ID"),

View File

@ -22,7 +22,7 @@ func TestLoadConfigDefaults(t *testing.T) {
if cfg.SpreadsheetID != "sheet-123" { if cfg.SpreadsheetID != "sheet-123" {
t.Errorf("SpreadsheetID: got %q", cfg.SpreadsheetID) t.Errorf("SpreadsheetID: got %q", cfg.SpreadsheetID)
} }
wantRange := fmt.Sprintf("%d!A1:F100", time.Now().Year()) wantRange := fmt.Sprintf("%d!A1:G100", time.Now().Year())
if cfg.SheetRange != wantRange { if cfg.SheetRange != wantRange {
t.Errorf("SheetRange default: got %q, want %q", cfg.SheetRange, wantRange) t.Errorf("SheetRange default: got %q, want %q", cfg.SheetRange, wantRange)
} }

View File

@ -16,7 +16,8 @@ const (
colPerson1 = 1 // "Osoba 1" colPerson1 = 1 // "Osoba 1"
colPerson2 = 2 // "Osoba 2" colPerson2 = 2 // "Osoba 2"
colName = 4 // "Lista osób" colName = 4 // "Lista osób"
colCount = 5 // "Liczba dyżurów" colHandle = 5 // Telegram handle (e.g. "@ala")
colCount = 6 // "Liczba dyżurów"
) )
// sheetEpoch is the date corresponding to serial number 0 in Google Sheets. // sheetEpoch is the date corresponding to serial number 0 in Google Sheets.
@ -29,9 +30,10 @@ type DutyWeek struct {
Person2 string // Osoba 2 — "" if unassigned Person2 string // Osoba 2 — "" if unassigned
} }
// Person is a single entry from the people list (columns EF). // Person is a single entry from the people list (columns EG).
type Person struct { type Person struct {
Name string // Lista osób Name string // Lista osób
Handle string // Telegram handle, verbatim from the sheet ("" if none)
DutyCount int // Liczba dyżurów DutyCount int // Liczba dyżurów
} }
@ -69,7 +71,7 @@ func ReadSheet(ctx context.Context, spreadsheetID, readRange string) ([][]interf
} }
// ParseRota interprets the raw rows from ReadSheet as two independent tables: // ParseRota interprets the raw rows from ReadSheet as two independent tables:
// the duty schedule (columns AC) and the people list (columns EF). // the duty schedule (columns AC) and the people list (columns EG).
// The header row is skipped; malformed cells are ignored, not reported. // The header row is skipped; malformed cells are ignored, not reported.
func ParseRota(rows [][]interface{}) Rota { func ParseRota(rows [][]interface{}) Rota {
var rota Rota var rota Rota
@ -95,6 +97,7 @@ func ParseRota(rows [][]interface{}) Rota {
count, _ := cellFloat(row, colCount) count, _ := cellFloat(row, colCount)
rota.People = append(rota.People, Person{ rota.People = append(rota.People, Person{
Name: name, Name: name,
Handle: cellString(row, colHandle),
DutyCount: int(count), DutyCount: int(count),
}) })
} }

View File

@ -6,22 +6,23 @@ import (
) )
// rows mirrors the real "Dyżury" sheet: cols AC are the duty schedule, // rows mirrors the real "Dyżury" sheet: cols AC are the duty schedule,
// cols EF are the person roster, with UNFORMATTED_VALUE rendering the // cols EG are the person roster (name, Telegram handle, duty count), with
// week date and duty count as float64 and trailing empty cells dropped. // UNFORMATTED_VALUE rendering the week date and duty count as float64 and
// trailing empty cells dropped.
func sampleRows() [][]interface{} { func sampleRows() [][]interface{} {
return [][]interface{}{ return [][]interface{}{
// row 0: header — must be skipped // row 0: header — must be skipped
{"Tydzień zaczynający się od", "Osoba 1", "Osoba 2", "", "Lista osób", "Liczba dyżurów"}, {"Tydzień zaczynający się od", "Osoba 1", "Osoba 2", "", "Lista osób", "Telegram", "Liczba dyżurów"},
// full week + roster entry // full week + roster entry (handle stored with leading @)
{float64(46027), "Jan Kowalski", "Anna Nowak", "", "Piotr Wiśniewski", float64(1)}, {float64(46027), "Jan Kowalski", "Anna Nowak", "", "Piotr Wiśniewski", "@piotr", float64(1)},
// empty week (no one assigned) + roster entry // empty week (no one assigned) + roster entry (handle without @)
{float64(46034), "", "", "", "Maria Wójcik", float64(0)}, {float64(46034), "", "", "", "Maria Wójcik", "maria", float64(0)},
// one-person week, ragged row (no roster columns at all) // one-person week, ragged row (no roster columns at all)
{float64(46041), "Tomasz Zieliński"}, {float64(46041), "Tomasz Zieliński"},
// week present, blank roster slot with a count -> person skipped // week present, blank roster slot with a count -> person skipped
{float64(46048), "", "", "", "", float64(0)}, {float64(46048), "", "", "", "", "", float64(0)},
// roster-only row (week column empty) -> person added, no week // roster-only row (week column empty) -> person added, no handle
{"", "", "", "", "Katarzyna Lewandowska", float64(3)}, {"", "", "", "", "Katarzyna Lewandowska", "", float64(3)},
} }
} }
@ -71,3 +72,19 @@ func TestParseRotaRosterSkipsBlankSlots(t *testing.T) {
t.Errorf("person2: got %+v, want {Katarzyna Lewandowska 3}", p) t.Errorf("person2: got %+v, want {Katarzyna Lewandowska 3}", p)
} }
} }
func TestParseRotaReadsHandles(t *testing.T) {
rota := ParseRota(sampleRows())
// Handles are stored verbatim from column F; normalization happens at
// render time, not at parse time.
if p := rota.People[0]; p.Handle != "@piotr" {
t.Errorf("person0 handle: got %q, want %q", p.Handle, "@piotr")
}
if p := rota.People[1]; p.Handle != "maria" {
t.Errorf("person1 handle: got %q, want %q", p.Handle, "maria")
}
if p := rota.People[2]; p.Handle != "" {
t.Errorf("person2 handle: got %q, want empty", p.Handle)
}
}