Skip to content

Commit 6f10977

Browse files
Aqua-218yuito-it
authored andcommitted
cron形式の変換機能を追加し、スケジュール設定のエラーメッセージを改善
1 parent 3906490 commit 6f10977

4 files changed

Lines changed: 394 additions & 9 deletions

File tree

Lines changed: 379 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
1+
package schedule
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"regexp"
7+
"strconv"
8+
"strings"
9+
"time"
10+
"unibot/internal/scheduler"
11+
)
12+
13+
type scheduleSpec interface {
14+
Next(after time.Time) time.Time
15+
}
16+
17+
type intervalUnit int
18+
19+
const (
20+
unitMinutes intervalUnit = iota
21+
unitHours
22+
unitDays
23+
unitWeeks
24+
unitMonths
25+
unitYears
26+
)
27+
28+
type intervalSchedule struct {
29+
interval int
30+
unit intervalUnit
31+
}
32+
33+
type dailyAtSchedule struct {
34+
hour int
35+
min int
36+
}
37+
38+
type weeklyAtSchedule struct {
39+
weekday time.Weekday
40+
hour int
41+
min int
42+
}
43+
44+
func (s intervalSchedule) Next(after time.Time) time.Time {
45+
base := normalizeTime(after)
46+
var next time.Time
47+
48+
switch s.unit {
49+
case unitMinutes:
50+
next = base.Add(time.Duration(s.interval) * time.Minute)
51+
case unitHours:
52+
next = base.Add(time.Duration(s.interval) * time.Hour)
53+
case unitDays:
54+
next = base.AddDate(0, 0, s.interval)
55+
case unitWeeks:
56+
next = base.AddDate(0, 0, 7*s.interval)
57+
case unitMonths:
58+
next = base.AddDate(0, s.interval, 0)
59+
case unitYears:
60+
next = base.AddDate(s.interval, 0, 0)
61+
}
62+
63+
if !next.After(after) {
64+
next = next.Add(time.Minute)
65+
}
66+
67+
return next
68+
}
69+
70+
func (s dailyAtSchedule) Next(after time.Time) time.Time {
71+
base := after.In(scheduler.JST())
72+
candidate := time.Date(base.Year(), base.Month(), base.Day(), s.hour, s.min, 0, 0, base.Location())
73+
if !candidate.After(after) {
74+
candidate = candidate.AddDate(0, 0, 1)
75+
}
76+
return candidate
77+
}
78+
79+
func (s weeklyAtSchedule) Next(after time.Time) time.Time {
80+
base := after.In(scheduler.JST())
81+
daysUntil := (int(s.weekday) - int(base.Weekday()) + 7) % 7
82+
candidate := time.Date(base.Year(), base.Month(), base.Day(), s.hour, s.min, 0, 0, base.Location()).AddDate(0, 0, daysUntil)
83+
if !candidate.After(after) {
84+
candidate = candidate.AddDate(0, 0, 7)
85+
}
86+
return candidate
87+
}
88+
89+
func normalizeTime(t time.Time) time.Time {
90+
loc := scheduler.JST()
91+
return time.Date(t.In(loc).Year(), t.In(loc).Month(), t.In(loc).Day(), t.In(loc).Hour(), t.In(loc).Minute(), 0, 0, loc)
92+
}
93+
94+
// convertToCron は自然言語の繰り返し指定をcronに変換する
95+
func convertToCron(input string) (string, error) {
96+
text := preprocessScheduleText(input)
97+
spec, err := parseScheduleText(text)
98+
if err != nil {
99+
return "", err
100+
}
101+
102+
now := time.Now().In(scheduler.JST())
103+
first := spec.Next(now)
104+
second := spec.Next(first)
105+
106+
diffMinutes := int(second.Sub(first).Minutes())
107+
if diffMinutes <= 0 {
108+
return "", errors.New("invalid schedule")
109+
}
110+
111+
min := first.Minute()
112+
hour := first.Hour()
113+
date := first.Day()
114+
month := int(first.Month())
115+
weekDay := int(first.Weekday())
116+
117+
switch {
118+
case diffMinutes < 60:
119+
return fmt.Sprintf("*/%d * * * *", diffMinutes), nil
120+
case diffMinutes%60 == 0 && diffMinutes < 1440:
121+
hours := diffMinutes / 60
122+
return fmt.Sprintf("%d */%d * * *", min, hours), nil
123+
case diffMinutes >= 1440 && diffMinutes < 10080:
124+
return fmt.Sprintf("%d %d * * *", min, hour), nil
125+
case diffMinutes >= 10080 && diffMinutes < 40320:
126+
return fmt.Sprintf("%d %d * * %d", min, hour, weekDay), nil
127+
case diffMinutes >= 40320 && diffMinutes < 525600:
128+
return fmt.Sprintf("%d %d %d * *", min, hour, date), nil
129+
case diffMinutes >= 525600:
130+
return fmt.Sprintf("%d %d %d %d *", min, hour, date, month), nil
131+
default:
132+
return "", errors.New("invalid schedule")
133+
}
134+
}
135+
136+
func preprocessScheduleText(input string) string {
137+
text := strings.TrimSpace(input)
138+
if text == "" {
139+
return ""
140+
}
141+
142+
reEveryDay := regexp.MustCompile(`(?i)^\s*every\s+day\s*$`)
143+
if reEveryDay.MatchString(text) {
144+
text = "every day at 9:00 am"
145+
}
146+
147+
reEveryDayAny := regexp.MustCompile(`(?i)\bevery\s+day\b`)
148+
if reEveryDayAny.MatchString(text) && !reEveryDay.MatchString(text) {
149+
text = strings.TrimSpace(reEveryDayAny.ReplaceAllString(text, ""))
150+
}
151+
152+
reZeroHour := regexp.MustCompile(`\b0:([0-5][0-9])\b`)
153+
text = reZeroHour.ReplaceAllString(text, "12:$1")
154+
155+
reTwelveWithoutAmPm := regexp.MustCompile(`\b12:([0-5][0-9])\b(?!\s?(am|pm))`)
156+
text = reTwelveWithoutAmPm.ReplaceAllString(text, "12:$1 am")
157+
158+
re24Hour := regexp.MustCompile(`\b([1][3-9]|2[0-3]):([0-5][0-9])\b`)
159+
text = re24Hour.ReplaceAllStringFunc(text, func(match string) string {
160+
parts := strings.Split(match, ":")
161+
if len(parts) != 2 {
162+
return match
163+
}
164+
hour, err := strconv.Atoi(parts[0])
165+
if err != nil {
166+
return match
167+
}
168+
minute := parts[1]
169+
ampmHour := hour - 12
170+
period := "pm"
171+
if hour < 12 {
172+
ampmHour = hour
173+
period = "am"
174+
}
175+
return fmt.Sprintf("%d:%s %s", ampmHour, minute, period)
176+
})
177+
178+
return strings.TrimSpace(text)
179+
}
180+
181+
func parseScheduleText(text string) (scheduleSpec, error) {
182+
if text == "" {
183+
return nil, errors.New("empty schedule")
184+
}
185+
186+
reAt := regexp.MustCompile(`(?i)^at\s+([0-9]{1,2}:[0-9]{2}(?:\s*(?:am|pm))?)$`)
187+
if match := reAt.FindStringSubmatch(text); match != nil {
188+
hour, min, err := parseTime(match[1])
189+
if err != nil {
190+
return nil, err
191+
}
192+
return dailyAtSchedule{hour: hour, min: min}, nil
193+
}
194+
195+
reDaily := regexp.MustCompile(`(?i)^every\s+day\s+at\s+([0-9]{1,2}:[0-9]{2}(?:\s*(?:am|pm))?)$`)
196+
if match := reDaily.FindStringSubmatch(text); match != nil {
197+
hour, min, err := parseTime(match[1])
198+
if err != nil {
199+
return nil, err
200+
}
201+
return dailyAtSchedule{hour: hour, min: min}, nil
202+
}
203+
204+
reWeekly := regexp.MustCompile(`(?i)^every\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)\s+at\s+([0-9]{1,2}:[0-9]{2}(?:\s*(?:am|pm))?)$`)
205+
if match := reWeekly.FindStringSubmatch(text); match != nil {
206+
weekday := parseWeekday(match[1])
207+
hour, min, err := parseTime(match[2])
208+
if err != nil {
209+
return nil, err
210+
}
211+
return weeklyAtSchedule{weekday: weekday, hour: hour, min: min}, nil
212+
}
213+
214+
reEvery := regexp.MustCompile(`(?i)^every\s+(\d+)\s+(minute|minutes|mins|hour|hours|day|days|week|weeks|month|months|year|years)$`)
215+
if match := reEvery.FindStringSubmatch(text); match != nil {
216+
interval, err := strconv.Atoi(match[1])
217+
if err != nil || interval <= 0 {
218+
return nil, errors.New("invalid interval")
219+
}
220+
221+
switch strings.ToLower(match[2]) {
222+
case "minute", "minutes", "mins":
223+
return intervalSchedule{interval: interval, unit: unitMinutes}, nil
224+
case "hour", "hours":
225+
return intervalSchedule{interval: interval, unit: unitHours}, nil
226+
case "day", "days":
227+
return intervalSchedule{interval: interval, unit: unitDays}, nil
228+
case "week", "weeks":
229+
return intervalSchedule{interval: interval, unit: unitWeeks}, nil
230+
case "month", "months":
231+
return intervalSchedule{interval: interval, unit: unitMonths}, nil
232+
case "year", "years":
233+
return intervalSchedule{interval: interval, unit: unitYears}, nil
234+
}
235+
}
236+
237+
return nil, errors.New("invalid schedule format")
238+
}
239+
240+
func parseTime(text string) (int, int, error) {
241+
re := regexp.MustCompile(`(?i)^(\d{1,2}):(\d{2})\s*(am|pm)?$`)
242+
match := re.FindStringSubmatch(strings.TrimSpace(text))
243+
if match == nil {
244+
return 0, 0, errors.New("invalid time")
245+
}
246+
247+
hour, err := strconv.Atoi(match[1])
248+
if err != nil {
249+
return 0, 0, errors.New("invalid time")
250+
}
251+
252+
min, err := strconv.Atoi(match[2])
253+
if err != nil {
254+
return 0, 0, errors.New("invalid time")
255+
}
256+
257+
if min < 0 || min > 59 {
258+
return 0, 0, errors.New("invalid time")
259+
}
260+
261+
ampm := strings.ToLower(match[3])
262+
if ampm != "" {
263+
if hour < 1 || hour > 12 {
264+
return 0, 0, errors.New("invalid time")
265+
}
266+
if ampm == "am" {
267+
if hour == 12 {
268+
hour = 0
269+
}
270+
}
271+
if ampm == "pm" {
272+
if hour != 12 {
273+
hour += 12
274+
}
275+
}
276+
} else {
277+
if hour < 0 || hour > 23 {
278+
return 0, 0, errors.New("invalid time")
279+
}
280+
}
281+
282+
return hour, min, nil
283+
}
284+
285+
func parseWeekday(text string) time.Weekday {
286+
switch strings.ToLower(text) {
287+
case "monday":
288+
return time.Monday
289+
case "tuesday":
290+
return time.Tuesday
291+
case "wednesday":
292+
return time.Wednesday
293+
case "thursday":
294+
return time.Thursday
295+
case "friday":
296+
return time.Friday
297+
case "saturday":
298+
return time.Saturday
299+
case "sunday":
300+
return time.Sunday
301+
default:
302+
return time.Sunday
303+
}
304+
}
305+
306+
func describeCron(cronText string) string {
307+
fields := strings.Fields(cronText)
308+
if len(fields) != 5 {
309+
return cronText
310+
}
311+
312+
min := fields[0]
313+
hour := fields[1]
314+
day := fields[2]
315+
month := fields[3]
316+
week := fields[4]
317+
318+
if strings.HasPrefix(min, "*/") && hour == "*" && day == "*" && month == "*" && week == "*" {
319+
return fmt.Sprintf("Every %s minutes", strings.TrimPrefix(min, "*/"))
320+
}
321+
322+
if strings.HasPrefix(hour, "*/") && day == "*" && month == "*" && week == "*" {
323+
return fmt.Sprintf("Every %s hours at minute %s", strings.TrimPrefix(hour, "*/"), min)
324+
}
325+
326+
if day == "*" && month == "*" && week == "*" {
327+
return fmt.Sprintf("At %s:%s every day", padHour(hour), padMinute(min))
328+
}
329+
330+
if day == "*" && month == "*" && week != "*" {
331+
return fmt.Sprintf("At %s:%s, only on %s", padHour(hour), padMinute(min), weekDayName(week))
332+
}
333+
334+
if day != "*" && month == "*" && week == "*" {
335+
return fmt.Sprintf("At %s:%s, on day %s of the month", padHour(hour), padMinute(min), day)
336+
}
337+
338+
if day != "*" && month != "*" && week == "*" {
339+
return fmt.Sprintf("At %s:%s, on day %s of month %s", padHour(hour), padMinute(min), day, month)
340+
}
341+
342+
return cronText
343+
}
344+
345+
func padHour(hour string) string {
346+
if len(hour) == 1 {
347+
return "0" + hour
348+
}
349+
return hour
350+
}
351+
352+
func padMinute(min string) string {
353+
if len(min) == 1 {
354+
return "0" + min
355+
}
356+
return min
357+
}
358+
359+
func weekDayName(week string) string {
360+
week = strings.TrimSpace(week)
361+
switch week {
362+
case "0", "7":
363+
return "Sunday"
364+
case "1":
365+
return "Monday"
366+
case "2":
367+
return "Tuesday"
368+
case "3":
369+
return "Wednesday"
370+
case "4":
371+
return "Thursday"
372+
case "5":
373+
return "Friday"
374+
case "6":
375+
return "Saturday"
376+
default:
377+
return week
378+
}
379+
}

src/internal/bot/command/general/schedule/list.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ func List(ctx *internal.BotContext, s *discordgo.Session, i *discordgo.Interacti
6868
for _, setting := range settings {
6969
repeatText := "いいえ"
7070
if setting.Cron != "" {
71-
repeatText = setting.Cron
71+
repeatText = describeCron(setting.Cron)
7272
}
7373

7474
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{

0 commit comments

Comments
 (0)