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
|
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{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
9
sheet.go
9
sheet.go
|
|
@ -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 E–F).
|
// Person is a single entry from the people list (columns E–G).
|
||||||
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 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.
|
// 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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,22 +6,23 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// rows mirrors the real "Dyżury" sheet: cols A–C are the duty schedule,
|
// 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
|
// cols E–G 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue