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:
parent
74cb302f64
commit
15b2fc9093
28
scheduler.go
28
scheduler.go
|
|
@ -94,12 +94,14 @@ func reminderText(rota Rota, target time.Time) (string, bool) {
|
|||
return "", false
|
||||
}
|
||||
|
||||
handles := handlesByName(rota.People)
|
||||
|
||||
var people []string
|
||||
if week.Person1 != "" {
|
||||
people = append(people, week.Person1)
|
||||
people = append(people, mention(week.Person1, handles))
|
||||
}
|
||||
if week.Person2 != "" {
|
||||
people = append(people, week.Person2)
|
||||
people = append(people, mention(week.Person2, handles))
|
||||
}
|
||||
|
||||
var body string
|
||||
|
|
@ -116,6 +118,28 @@ func reminderText(rota Rota, target time.Time) (string, bool) {
|
|||
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.
|
||||
func parseWeekday(s string) (time.Weekday, error) {
|
||||
days := map[string]time.Weekday{
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
tests := []struct {
|
||||
in string
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ func LoadConfig() (Config, error) {
|
|||
|
||||
// Default range targets the current year's tab (sheets are named per year,
|
||||
// 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{
|
||||
SpreadsheetID: os.Getenv("GOOGLE_SHEETS_ID"),
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ func TestLoadConfigDefaults(t *testing.T) {
|
|||
if cfg.SpreadsheetID != "sheet-123" {
|
||||
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 {
|
||||
t.Errorf("SheetRange default: got %q, want %q", cfg.SheetRange, wantRange)
|
||||
}
|
||||
|
|
|
|||
9
sheet.go
9
sheet.go
|
|
@ -16,7 +16,8 @@ const (
|
|||
colPerson1 = 1 // "Osoba 1"
|
||||
colPerson2 = 2 // "Osoba 2"
|
||||
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.
|
||||
|
|
@ -29,9 +30,10 @@ type DutyWeek struct {
|
|||
Person2 string // Osoba 2 — "" if unassigned
|
||||
}
|
||||
|
||||
// Person is a single entry from the people list (columns E–F).
|
||||
// Person is a single entry from the people list (columns E–G).
|
||||
type Person struct {
|
||||
Name string // Lista osób
|
||||
Handle string // Telegram handle, verbatim from the sheet ("" if none)
|
||||
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:
|
||||
// the duty schedule (columns A–C) and the people list (columns E–F).
|
||||
// the duty schedule (columns A–C) and the people list (columns E–G).
|
||||
// The header row is skipped; malformed cells are ignored, not reported.
|
||||
func ParseRota(rows [][]interface{}) Rota {
|
||||
var rota Rota
|
||||
|
|
@ -95,6 +97,7 @@ func ParseRota(rows [][]interface{}) Rota {
|
|||
count, _ := cellFloat(row, colCount)
|
||||
rota.People = append(rota.People, Person{
|
||||
Name: name,
|
||||
Handle: cellString(row, colHandle),
|
||||
DutyCount: int(count),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,22 +6,23 @@ import (
|
|||
)
|
||||
|
||||
// rows mirrors the real "Dyżury" sheet: cols A–C are the duty schedule,
|
||||
// cols E–F are the person roster, with UNFORMATTED_VALUE rendering the
|
||||
// week date and duty count as float64 and trailing empty cells dropped.
|
||||
// cols E–G are the person roster (name, Telegram handle, duty count), with
|
||||
// UNFORMATTED_VALUE rendering the week date and duty count as float64 and
|
||||
// trailing empty cells dropped.
|
||||
func sampleRows() [][]interface{} {
|
||||
return [][]interface{}{
|
||||
// row 0: header — must be skipped
|
||||
{"Tydzień zaczynający się od", "Osoba 1", "Osoba 2", "", "Lista osób", "Liczba dyżurów"},
|
||||
// full week + roster entry
|
||||
{float64(46027), "Jan Kowalski", "Anna Nowak", "", "Piotr Wiśniewski", float64(1)},
|
||||
// empty week (no one assigned) + roster entry
|
||||
{float64(46034), "", "", "", "Maria Wójcik", float64(0)},
|
||||
{"Tydzień zaczynający się od", "Osoba 1", "Osoba 2", "", "Lista osób", "Telegram", "Liczba dyżurów"},
|
||||
// full week + roster entry (handle stored with leading @)
|
||||
{float64(46027), "Jan Kowalski", "Anna Nowak", "", "Piotr Wiśniewski", "@piotr", float64(1)},
|
||||
// empty week (no one assigned) + roster entry (handle without @)
|
||||
{float64(46034), "", "", "", "Maria Wójcik", "maria", float64(0)},
|
||||
// one-person week, ragged row (no roster columns at all)
|
||||
{float64(46041), "Tomasz Zieliński"},
|
||||
// week present, blank roster slot with a count -> person skipped
|
||||
{float64(46048), "", "", "", "", float64(0)},
|
||||
// roster-only row (week column empty) -> person added, no week
|
||||
{"", "", "", "", "Katarzyna Lewandowska", float64(3)},
|
||||
{float64(46048), "", "", "", "", "", float64(0)},
|
||||
// roster-only row (week column empty) -> person added, no handle
|
||||
{"", "", "", "", "Katarzyna Lewandowska", "", float64(3)},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -71,3 +72,19 @@ func TestParseRotaRosterSkipsBlankSlots(t *testing.T) {
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue