From 49356f78533f9b48859119e046132d99cc69ece8 Mon Sep 17 00:00:00 2001 From: Unicatevn Date: Fri, 22 Aug 2025 17:08:57 +0700 Subject: [PATCH] add submission watch --- internal/bot/bot.go | 81 +++++++++++++++++++++++++++++++++ internal/database/database.go | 38 ++++++++++++++++ internal/leetcode/leetcode.go | 73 +++++++++++++++++++++++++++++ internal/models/models.go | 8 ++++ internal/scheduler/scheduler.go | 11 +++++ 5 files changed, 211 insertions(+) create mode 100644 internal/leetcode/leetcode.go diff --git a/internal/bot/bot.go b/internal/bot/bot.go index 55381ad..75a5df5 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -9,6 +9,7 @@ import ( "leetcode-telegram-bot/internal/config" "leetcode-telegram-bot/internal/database" + "leetcode-telegram-bot/internal/leetcode" "leetcode-telegram-bot/internal/models" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" @@ -88,6 +89,8 @@ func (b *Bot) handleMessage(message *tgbotapi.Message) { b.handleStatusCommand(message) case "resetday": b.handleResetDayCommand(message) + case "register": + b.handleRegisterLeetcodeProfile(message) default: b.sendMessage(message.Chat.ID, "Unknown command. Use /help to see available commands.") } @@ -445,3 +448,81 @@ func (b *Bot) SendReminder() error { log.Printf("Sent reminder to %d users for Day %d", len(users), dayNumber) return nil } + +func (b *Bot) CheckSubmissions() error { + today := time.Now().Format("2006-01-02") + + // Get users who haven't submitted today + users, err := b.db.GetUsersWhoDidntSubmitToday(today) + if err != nil { + return fmt.Errorf("failed to get users who didn't submit: %w", err) + } + + if len(users) == 0 { + log.Println("All users have submitted today!") + return nil + } + + // Get today's challenge with day number + todaysChallenge, dayNumber, err := b.db.GetTodaysChallengeWithDay(today) + if err != nil { + return fmt.Errorf("failed to get today's challenge: %w", err) + } + + for _, user := range users { + leetcodeProfile, err := b.db.GetLeetcodeProfile(user.ID) + if err != nil { + log.Printf("Error getting LeetCode profile for user %d: %v", user.ID, err) + continue + } + + submissions, err := leetcode.GetRecentACByUsername(leetcodeProfile.Username) + done := false + for _, submission := range submissions { + if submission.Title == todaysChallenge.Title && submission.Timestamp.Format("2006-01-02") == today { + done = true + break + } + } + + if done { + // Announce submission in group + var mention string + if user.Username != "" { + mention = "@" + user.Username + } else { + mention = user.FirstName + } + + messageText := fmt.Sprintf("🎉 %s has just submitted today's challenge (Day %d):\n\n", mention, dayNumber) + + b.sendMessage(b.config.TelegramGroupID, messageText) + } + } + return nil +} + +func (b *Bot) handleRegisterLeetcodeProfile(message *tgbotapi.Message) error { + userID := message.From.ID + username := strings.TrimSpace(message.CommandArguments()) + if username == "" { + b.sendMessage(message.Chat.ID, "❌ Please provide your LeetCode username. Usage: /register ") + return nil + } + + // Check if user is already registered + existingProfile, err := b.db.GetLeetcodeProfile(userID) + if err != nil && existingProfile != nil { + b.sendMessage(message.Chat.ID, "❌ You have already registered your LeetCode username.") + return nil + } + + err = b.db.RegisterLeetcodeProfile(userID, username) + if err != nil { + log.Printf("Error registering LeetCode profile: %v", err) + b.sendMessage(message.Chat.ID, "❌ An error occurred while registering your LeetCode username.") + return err + } + b.sendMessage(message.Chat.ID, fmt.Sprintf("✅ Successfully registered your LeetCode username: %s", username)) + return nil +} diff --git a/internal/database/database.go b/internal/database/database.go index bc3823a..b500700 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -70,6 +70,13 @@ func (db *DB) createTables() error { day_number INTEGER NOT NULL, FOREIGN KEY (problem_id) REFERENCES problems (id) )`, + `CREATE TABLE IF NOT EXISTS user_leetcode_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + leetcode_username TEXT NOT NULL UNIQUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + FOREIGN KEY (user_id) REFERENCES users (id) + )`, `CREATE TABLE IF NOT EXISTS challenge_counter ( id INTEGER PRIMARY KEY CHECK (id = 1), current_day INTEGER NOT NULL DEFAULT 9, @@ -320,3 +327,34 @@ func (db *DB) LoadProblemsFromYAML(problems models.ProblemsData) error { return tx.Commit() } + +// GetLeetcodeProfile gets leetcode profile of user by id +func (db *DB) GetLeetcodeProfile(id int64) (*models.User, error) { + query := `SELECT u.id, u.leetcode_username + FROM user_leetcode_profiles u + WHERE u.id = ?` + + rows, err := db.conn.Query(query, id) + if err != nil { + return nil, err + } + defer rows.Close() + + if rows.Next() { + var user models.User + err := rows.Scan(&user.ID, &user.Username, &user.FirstName, &user.LastName) + if err != nil { + return nil, err + } + return &user, nil + } + return nil, fmt.Errorf("no leetcode profile found for user with id %d", id) +} + +// RegisterLeetcodeProfile registers a leetcode profile for a user +func (db *DB) RegisterLeetcodeProfile(userID int64, leetcodeUsername string) error { + query := `INSERT OR REPLACE INTO user_leetcode_profiles (user_id, leetcode_username, created_at) + VALUES (?, ?, COALESCE((SELECT created_at FROM user_leetcode_profiles WHERE user_id = ?), CURRENT_TIMESTAMP))` + _, err := db.conn.Exec(query, userID, leetcodeUsername, userID) + return err +} diff --git a/internal/leetcode/leetcode.go b/internal/leetcode/leetcode.go new file mode 100644 index 0000000..af6460c --- /dev/null +++ b/internal/leetcode/leetcode.go @@ -0,0 +1,73 @@ +package leetcode + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +type RecentACEntry struct { + Title string `json:"title"` + TitleSlug string `json:"titleSlug"` + Timestamp string `json:"timestamp"` +} + +type RecentACData struct { + RecentACSubmissionList []RecentACEntry `json:"recentAcSubmissionList"` +} + +type RecentACListResponse struct { + Data RecentACData `json:"data"` +} + +type RecentAC struct { + Title string + TitleSlug string + Timestamp time.Time +} + +func queryRecentACList(username string) ([]RecentACEntry, error) { + // Call leetcode graphql api + query := fmt.Sprintf(`{"query":"\n query recentAcSubmissions($username: String!, $limit: Int!) {\n recentAcSubmissionList(username: $username, limit: $limit) {\n id\n title\n titleSlug\n timestamp\n }\n}\n ","variables":{"username":"%s","limit":15},"operationName":"recentAcSubmissions"}`, username) + resp, err := http.Post("https://leetcode.com/graphql", "application/json", strings.NewReader(query)) + if err != nil { + return nil, fmt.Errorf("failed to fetch recent AC submissions: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch recent AC submissions: %s", resp.Status) + } + var result RecentACListResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + return result.Data.RecentACSubmissionList, nil +} + +func GetRecentACByUsername(username string) ([]RecentAC, error) { + if username == "" { + return nil, fmt.Errorf("username cannot be empty") + } + recentACList, err := queryRecentACList(username) + if err != nil { + return nil, fmt.Errorf("failed to get recent AC submissions for user %s: %w", username, err) + } + var result []RecentAC + for _, ac := range recentACList { + i, err := strconv.ParseInt(ac.Timestamp, 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse timestamp %s: %w", ac.Timestamp, err) + } + timestamp := time.Unix(i, 0) + result = append(result, RecentAC{ + Title: ac.Title, + TitleSlug: ac.TitleSlug, + Timestamp: timestamp, + }) + } + return result, nil +} + diff --git a/internal/models/models.go b/internal/models/models.go index aed24e5..7ac9b12 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -22,6 +22,14 @@ type User struct { CreatedAt time.Time `json:"created_at" db:"created_at"` } +// UserLeetcodeProfile represents a user's LeetCode profile +type UserLeetcodeProfile struct { + ID int64 `json:"id" db:"id"` + UserId int64 `json:"user_id" db:"user_id"` + LeetCodeUsername string `json:"leetcode_username" db:"leetcode_username"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + // Submission represents a user's submission for a daily challenge type Submission struct { ID int `json:"id" db:"id"` diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 5cc3e14..d2eb930 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -81,6 +81,17 @@ func (s *Scheduler) Start() { log.Printf("Error scheduling evening reminder: %v", err) } + // Schedule check submissions every 5 minutes + _, err = s.cron.AddFunc("*/5 * * * *", func() { + log.Println("Checking new submissions...") + if err := s.bot.CheckSubmissions(); err != nil { + log.Printf("Error while checking new submissions %v", err) + } + }) + if err != nil { + log.Printf("Error scheduling check new submissions: %v", err) + } + // Start the cron scheduler s.cron.Start() log.Println("Scheduler started successfully - posting challenges Monday to Friday only")