Skip to content

Commit 0df4858

Browse files
amtiYoclaude
andcommitted
feat(bot): group post time, location answers, blocks
Under a group's Settings an admin moves the morning post by an hour or a quarter within 05:00-19:00 and turns the intrigue and the streak mentions on or off; every press asks Telegram for the rights again. A post still waiting for its first attempt follows the new time the same day, since PlanJob now moves an untouched job. A location sent in a DM is the day's answer, with the Mini App's rules: one answer, the earliest reveal among the person's chats as the deadline. Help mentions it in all sixteen languages. A DM whose person blocked maply is marked inactive; unblocking or any message brings it back. The profile description and short description are published per language at start, and only written when they differ. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 9f08bd1 commit 0df4858

13 files changed

Lines changed: 493 additions & 36 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ of the Go service.
1717

1818
### Added
1919

20+
- A group admin moves the morning post by an hour or a quarter (05:00–19:00)
21+
and turns the intrigue and the streak mentions on or off under Settings. A
22+
post that has not gone out yet follows the new time the same day.
23+
- A location sent to maply in a DM is the day's answer, under the Mini App's
24+
rules: one a day, refused after the deadline. Help says so.
25+
- Blocking maply stops the day in that DM; unblocking or writing to it starts
26+
it again. The bot's profile description and short description are set in
27+
every language at start, and written only when they changed.
2028
- The day runs by itself (`internal/schedule`, `005_schedule.sql`): every
2129
chat gets the morning post with the photo, an intrigue between 16:00 and
2230
18:00 and the result at 20:00, each in its own zone. Sends are rows in the

‎internal/bot/bot.go‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,16 @@ type Store interface {
2929
SetChatZone(ctx context.Context, chatID int64, zone string) error
3030
AdoptChatZone(ctx context.Context, chatID int64, zone string) error
3131
LeaveChat(ctx context.Context, chatID int64) error
32+
ReturnChat(ctx context.Context, chatID int64) error
3233
Stats(ctx context.Context, userID int64) (db.Stats, error)
34+
35+
ChatSettings(ctx context.Context, chatID int64) (db.Settings, error)
36+
MovePost(ctx context.Context, chatID int64, minutes int) error
37+
SetSwitch(ctx context.Context, chatID int64, name string, on bool) error
38+
39+
RoundOn(ctx context.Context, d round.Date) (db.Round, bool, error)
40+
Contexts(ctx context.Context, userID int64) ([]db.Context, error)
41+
InsertGuess(ctx context.Context, g db.NewGuess) error
3342
}
3443

3544
type Telegram interface {
@@ -42,6 +51,7 @@ type Telegram interface {
4251
AnswerCallbackQuery(ctx context.Context, callbackID, text string) error
4352
AnswerCallbackQueryAlert(ctx context.Context, callbackID, text string) error
4453
GetChatMember(ctx context.Context, chatID, userID int64) (tg.ChatMember, error)
54+
SetDescriptions(ctx context.Context, lang, description, short string) error
4555
}
4656

4757
// Options are what the panels show that the bot cannot learn from an update.
@@ -175,10 +185,20 @@ func (b *Bot) handle(ctx context.Context, u tg.Update) {
175185
if err := b.store.Touch(ctx, *user, chat); err != nil {
176186
b.log.Error("core touch failed", "update_id", u.UpdateID, "error", err)
177187
}
188+
// Whoever writes to maply gets the day again, after a block too.
189+
if u.Message != nil && !user.IsBot {
190+
if err := b.store.ReturnChat(ctx, chat.ID); err != nil {
191+
b.log.Error("return chat failed", "update_id", u.UpdateID, "error", err)
192+
}
193+
}
178194
var err error
179195
switch {
180196
case u.Message != nil && b.isStart(u.Message):
181197
err = b.sendHome(ctx, u.Message)
198+
case u.Message != nil && u.Message.Chat.Type == "private":
199+
if p, ok := locationOf(u.Message); ok {
200+
err = b.onLocation(ctx, u.Message, p)
201+
}
182202
case u.Callback != nil:
183203
err = b.onCallback(ctx, u.Callback)
184204
case u.MyChatMember != nil:

‎internal/bot/bot_test.go‎

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/FreshLabDev/tg"
1313

1414
"github.com/FreshLabDev/maply/internal/db"
15+
"github.com/FreshLabDev/maply/internal/round"
1516
"github.com/FreshLabDev/maply/internal/telegram"
1617
)
1718

@@ -28,11 +29,16 @@ type fakeStore struct {
2829
chatLang map[int64]string
2930
zones map[int64]string
3031
left []int64
32+
returned []int64
3133
stats db.Stats
34+
settings map[int64]db.Settings
35+
rounds map[string]bool
36+
guesses []db.NewGuess
3237
}
3338

3439
func newStore() *fakeStore {
35-
return &fakeStore{userLang: map[int64]string{}, chatLang: map[int64]string{}, zones: map[int64]string{}}
40+
return &fakeStore{userLang: map[int64]string{}, chatLang: map[int64]string{}, zones: map[int64]string{},
41+
settings: map[int64]db.Settings{}, rounds: map[string]bool{}}
3642
}
3743

3844
func (f *fakeStore) Offset(context.Context) (int64, error) {
@@ -92,7 +98,48 @@ func (f *fakeStore) LeaveChat(_ context.Context, id int64) error {
9298
f.left = append(f.left, id)
9399
return nil
94100
}
101+
func (f *fakeStore) ReturnChat(_ context.Context, id int64) error {
102+
f.returned = append(f.returned, id)
103+
return nil
104+
}
95105
func (f *fakeStore) Stats(context.Context, int64) (db.Stats, error) { return f.stats, nil }
106+
func (f *fakeStore) ChatSettings(_ context.Context, id int64) (db.Settings, error) {
107+
st, ok := f.settings[id]
108+
if !ok {
109+
st = db.Settings{PostHour: 7, Intrigue: true, Mentions: true}
110+
}
111+
return st, nil
112+
}
113+
func (f *fakeStore) MovePost(ctx context.Context, id int64, minutes int) error {
114+
st, _ := f.ChatSettings(ctx, id)
115+
m := min(max(st.PostHour*60+st.PostMin+minutes, 5*60), 19*60)
116+
st.PostHour, st.PostMin = m/60, m%60
117+
f.settings[id] = st
118+
return nil
119+
}
120+
func (f *fakeStore) SetSwitch(ctx context.Context, id int64, name string, on bool) error {
121+
st, _ := f.ChatSettings(ctx, id)
122+
if name == "intrigue" {
123+
st.Intrigue = on
124+
} else {
125+
st.Mentions = on
126+
}
127+
f.settings[id] = st
128+
return nil
129+
}
130+
func (f *fakeStore) RoundOn(_ context.Context, d round.Date) (db.Round, bool, error) {
131+
return db.Round{Date: d, DayNo: 1, PlaceID: 1}, f.rounds[d.String()], nil
132+
}
133+
func (f *fakeStore) Contexts(context.Context, int64) ([]db.Context, error) { return nil, nil }
134+
func (f *fakeStore) InsertGuess(_ context.Context, g db.NewGuess) error {
135+
for _, old := range f.guesses {
136+
if old.UserID == g.UserID && old.Date.String() == g.Date.String() {
137+
return db.ErrAlreadyAnswered
138+
}
139+
}
140+
f.guesses = append(f.guesses, g)
141+
return nil
142+
}
96143

97144
type sent struct {
98145
chatID, messageID int64
@@ -107,16 +154,23 @@ type fakeTelegram struct {
107154
offsets []int64
108155
cancel context.CancelFunc
109156
commands int
110-
sends []sent
111-
edits []sent
112-
deleted []int64
113-
alerts []string
114-
answers int
115-
members map[int64]string
157+
// descriptions counts the languages the profile texts were written in.
158+
descriptions int
159+
sends []sent
160+
edits []sent
161+
deleted []int64
162+
alerts []string
163+
answers int
164+
members map[int64]string
116165
}
117166

118167
func (f *fakeTelegram) DeleteWebhook(context.Context) error { return nil }
119168

169+
func (f *fakeTelegram) SetDescriptions(context.Context, string, string, string) error {
170+
f.descriptions++
171+
return nil
172+
}
173+
120174
func (f *fakeTelegram) GetUpdates(ctx context.Context, offset int64, _ int) ([]tg.Update, error) {
121175
f.offsets = append(f.offsets, offset)
122176
if len(f.offsets) == 1 {
@@ -184,6 +238,9 @@ func TestRunTouchesSendersAndAdvancesOffset(t *testing.T) {
184238
if client.commands != 17 {
185239
t.Fatalf("published commands %d times, want the default and 16 languages", client.commands)
186240
}
241+
if client.descriptions != 17 {
242+
t.Fatalf("published descriptions %d times, want 17", client.descriptions)
243+
}
187244
if store.offset != 43 {
188245
t.Fatalf("stored offset = %d, want 43", store.offset)
189246
}

‎internal/bot/location.go‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
package bot
3+
4+
import (
5+
"context"
6+
"encoding/json"
7+
"errors"
8+
"time"
9+
10+
"github.com/FreshLabDev/tg"
11+
12+
"github.com/FreshLabDev/maply/internal/db"
13+
"github.com/FreshLabDev/maply/internal/geo"
14+
"github.com/FreshLabDev/maply/internal/i18n"
15+
"github.com/FreshLabDev/maply/internal/round"
16+
)
17+
18+
// locationOf reads a location the person sent. A live location is an answer
19+
// too: its first point is where they meant.
20+
func locationOf(m *tg.Message) (geo.Point, bool) {
21+
if len(m.Raw) == 0 {
22+
return geo.Point{}, false
23+
}
24+
var raw struct {
25+
Location *struct {
26+
Latitude float64 `json:"latitude"`
27+
Longitude float64 `json:"longitude"`
28+
} `json:"location"`
29+
}
30+
if err := json.Unmarshal(m.Raw, &raw); err != nil || raw.Location == nil {
31+
return geo.Point{}, false
32+
}
33+
p := geo.Point{Lat: raw.Location.Latitude, Lon: raw.Location.Longitude}
34+
return p, p.Valid()
35+
}
36+
37+
// onLocation takes a location in a DM as the day's answer, for when the Mini
38+
// App does not open. The rules are the Mini App's: one answer a day, and the
39+
// window closes at the earliest reveal among the person's chats.
40+
func (b *Bot) onLocation(ctx context.Context, m *tg.Message, p geo.Point) error {
41+
raw, err := b.personLang(ctx, *m.From)
42+
if err != nil {
43+
return err
44+
}
45+
lang := i18n.Resolve(raw)
46+
zone, err := b.store.ChatZone(ctx, m.From.ID)
47+
if err != nil {
48+
return err
49+
}
50+
loc := b.opts.Zones.For(zone, raw)
51+
now := b.opts.Now()
52+
d := round.DateIn(now, loc)
53+
reply := func(key string) error {
54+
_, err := b.tg.SendText(ctx, m.Chat.ID, i18n.T(lang, key, "time", revealTime()), nil)
55+
return err
56+
}
57+
if _, ok, err := b.store.RoundOn(ctx, d); err != nil {
58+
return err
59+
} else if !ok {
60+
return reply("loc.closed")
61+
}
62+
deadline, err := b.deadline(ctx, m.From.ID, d, loc)
63+
if err != nil {
64+
return err
65+
}
66+
if now.After(deadline) {
67+
return reply("loc.closed")
68+
}
69+
err = b.store.InsertGuess(ctx, db.NewGuess{
70+
Date: d, UserID: m.From.ID, Context: "dm", Pin: p, AnsweredAt: now, Deadline: deadline,
71+
})
72+
if errors.Is(err, db.ErrAlreadyAnswered) {
73+
return reply("loc.already")
74+
}
75+
if err != nil {
76+
return err
77+
}
78+
return reply("loc.accepted")
79+
}
80+
81+
// deadline is the earliest reveal among the DM and the person's groups, as
82+
// /api/guess counts it.
83+
func (b *Bot) deadline(ctx context.Context, userID int64, d round.Date, own *time.Location) (time.Time, error) {
84+
contexts, err := b.store.Contexts(ctx, userID)
85+
if err != nil {
86+
return time.Time{}, err
87+
}
88+
locs := []*time.Location{own}
89+
for _, c := range contexts {
90+
locs = append(locs, b.opts.Zones.For(c.Zone, c.Lang))
91+
}
92+
return round.Deadline(d, locs)
93+
}

‎internal/bot/menu.go‎

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,18 @@ const (
5050
actZone = "zone"
5151
actGroupLang = "glang"
5252
actGroupAuto = "glangauto"
53+
actPost = "post"
54+
actSwitch = "sw"
5355
actClose = "close"
5456
)
5557

58+
// postSteps are how far one press moves a group's morning post, in minutes.
59+
var postSteps = map[int]bool{-60: true, -15: true, 15: true, 60: true}
60+
5661
func adminOnly(action string) bool {
5762
head, _, _ := strings.Cut(action, ":")
5863
switch head {
59-
case actSettings, actZone, actGroupLang, actGroupAuto:
64+
case actSettings, actZone, actGroupLang, actGroupAuto, actPost, actSwitch:
6065
return true
6166
}
6267
return false
@@ -258,14 +263,19 @@ func (v view) groupLanguage(current string) (string, telegram.Markup) {
258263
}
259264

260265
// settings shows the zone the result follows. A group's also has its
261-
// language; a person's language is on the home screen.
262-
func (v view) settings(zone, groupLang string) (string, telegram.Markup) {
266+
// language, when the morning post comes and the two switches; a person's
267+
// language is on the home screen.
268+
func (v view) settings(zone, groupLang string, st *db.Settings) (string, telegram.Markup) {
263269
hint := v.t("settings.hint")
264270
if v.sc == scopeGroup {
265271
hint = v.t("settings.group_hint")
266272
}
267273
zoneName := v.t("zone." + zone)
268-
text := panel(v.t("settings.title"), "", hint, quote(v.t("settings.reveal", "time", revealTime(), "zone", zoneName)))
274+
body := v.t("settings.reveal", "time", revealTime(), "zone", zoneName)
275+
if st != nil {
276+
body += "\n" + v.t("settings.post", "time", fmt.Sprintf("%02d:%02d", st.PostHour, st.PostMin))
277+
}
278+
text := panel(v.t("settings.title"), "", hint, quote(body))
269279
rows := telegram.Markup{{{Text: v.t("btn.zone", "zone", zoneName), CallbackData: cb(v.owner, actZone)}}}
270280
if v.sc == scopeGroup {
271281
label := v.t("btn.lang_auto")
@@ -274,6 +284,23 @@ func (v view) settings(zone, groupLang string) (string, telegram.Markup) {
274284
}
275285
rows = append(rows, []telegram.Button{{Text: v.t("btn.group_language", "language", label), CallbackData: cb(v.owner, actGroupLang)}})
276286
}
287+
if st != nil {
288+
step := func(label string, minutes int) telegram.Button {
289+
return telegram.Button{Text: label, CallbackData: cb(v.owner, actPost+":"+strconv.Itoa(minutes))}
290+
}
291+
toggle := func(key, name string, on bool) telegram.Button {
292+
b := telegram.Button{Text: toggleMark(on) + v.t(key), CallbackData: cb(v.owner, actSwitch+":"+name)}
293+
if on {
294+
b.Style = tg.StyleSuccess
295+
}
296+
return b
297+
}
298+
rows = append(rows,
299+
[]telegram.Button{step("−1:00", -60), step("−0:15", -15), step("+0:15", 15), step("+1:00", 60)},
300+
[]telegram.Button{toggle("btn.intrigue", "intrigue", st.Intrigue)},
301+
[]telegram.Button{toggle("btn.mentions", "mentions", st.Mentions)},
302+
)
303+
}
277304
return text, append(rows, v.navRow(actHome))
278305
}
279306

0 commit comments

Comments
 (0)