Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
GEMINI_API_KEY=your_gemini_api_key_here
SUPABASE_URL=your_supabase_url
SUPABASE_URL=your_supabase_url
GEMINI_MODELS=add_your_gemini_models_here_separated_by_commas
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ B4. Sau khi chạy xong, bạn sẽ có file `.env` trong thư mục với các
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
SUPABASE_URL=your_supabase_url
GEMINI_API_KEY=your_gemini_api_key
GEMINI_MODELS=add_your_gemini_models_here_separated_by_commas
```

Thay các token trong file `.env` vừa được tạo bằng token của bạn, cách lấy như sau:
Expand All @@ -191,6 +192,10 @@ Thay các token trong file `.env` vừa được tạo bằng token của bạn,

- **Gemini API Key**: tạo tài khoản trên [Google AI Studio](https://aistudio.google.com/), chọn `Get API Key` và lấy key.

- **Gemini Models** *(tùy chọn)*: list model Gemini dùng để tóm tắt, phân tách bằng dấu phẩy. Ví dụ: `gemini-3.5-flash-lite,gemini-3.7-flash,gemini-3.6-flash`.

> Bạn có thể check các model Gemini có sẵn tại [https://ai.google.dev/gemini-api/docs/models](https://ai.google.dev/gemini-api/docs/models) và thêm vào các model tương thích với nhu cầu của bạn.

B5. Run bot:

```zsh
Expand Down
46 changes: 46 additions & 0 deletions config/models.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package config

import (
"log"
"os"
"strings"
"sync"
)

// default models to use if GEMINI_MODELS env is not set (fallback order)
var defaultModels = []string{
"gemini-3.5-flash-lite",
"gemini-3.7-flash",
"gemini-3.6-flash",
"gemini-3.5-flash",
}

var (
models []string
modelsOnce sync.Once
)

// Models returns gemini models from GEMINI_MODELS env (comma-separated),
// falling back to default list if unset.
func Models() []string {
modelsOnce.Do(func() {
raw := strings.TrimSpace(os.Getenv("GEMINI_MODELS"))
if raw == "" {
models = defaultModels
log.Printf("GEMINI_MODELS not set, using default models: %v", models)
return
}

for m := range strings.SplitSeq(raw, ",") {
if m = strings.TrimSpace(m); m != "" {
models = append(models, m)
}
}

if len(models) == 0 {
models = defaultModels
}
})

return models
}
14 changes: 11 additions & 3 deletions internal/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,16 @@ func runCronCycle(b *tele.Bot, dbPool *pgxpool.Pool) {
close(jobs)

for range newArticles {
res := <-results // receive summary from gemini
service.SendTele(b, users, res) // send for subscribed users
service.SaveToDB(dbPool, res) // save to db
res := <-results // receive summary from gemini

// content fetch failed but will be retried next cycle,
// do not send or save so the article is not lost
if res.Retryable {
log.Printf("Fetch failed, will retry next cycle: %s", res.Article.URL)
continue
}

service.SendTele(b, dbPool, users, res) // send for subscribed users
service.SaveToDB(dbPool, res) // save to db
}
}
1 change: 1 addition & 0 deletions internal/model/Summary.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ type (
Summary string
PromptToken int
CompletionToken int
Retryable bool // true when fetch failed and should be retried next cycle
}
)
32 changes: 32 additions & 0 deletions internal/service/handle_unreachable_user.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package service

import (
"context"
"errors"
"hcmus-news-tele-bot/internal/repository"
"log"
"time"

"github.com/jackc/pgx/v5/pgxpool"
tele "gopkg.in/telebot.v4"
)

// isUserUnreachable checks if the user blocked the bot or deactivated their account,
// meaning further sending attempts are pointless.
func isUserUnreachable(err error) bool {
return errors.Is(err, tele.ErrBlockedByUser) ||
errors.Is(err, tele.ErrUserIsDeactivated) ||
errors.Is(err, tele.ErrNotStartedByUser)
}

func autoUnsubscribe(dbPool *pgxpool.Pool, userID string) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

if err := repository.UnsubscribeUser(dbPool, ctx, userID); err != nil {
log.Printf("Error auto-unsubscribing user %s: %v\n", userID, err)
return
}

log.Printf("User %s blocked/deactivated the bot, auto-unsubscribed", userID)
}
69 changes: 69 additions & 0 deletions internal/service/prompt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package service

const summarizePrompt = `
Bạn là một biên tập viên tin tức chuyên nghiệp, có nhiệm vụ tóm tắt các bài báo tiếng Việt của HCMUS cho người đọc.
Nhiệm vụ của bạn là:
- Tóm tắt bài báo bên dưới thành 3 câu ngắn gọn nhưng đầy đủ ý chính.

Mục tiêu là để một người khi thấy thông báo tin mới, chưa mở bài báo gốc vẫn có thể hiểu:
- Bài báo đang nói về sự kiện/vấn đề gì.
- Những diễn biến hoặc thông tin quan trọng nhất.
- Các nhân vật, tổ chức, địa điểm, công ty, đơn vị, thời gian hoặc con số đáng chú ý.
- Kết quả, tác động hoặc thông tin đáng chú ý khác nếu có.

RULE QUAN TRỌNG KHI TÓM TẮT BÀI BÁO:
- Chỉ sử dụng thông tin xuất hiện trong bài báo.
- Tuyệt đối không bịa, suy diễn hoặc bổ sung thông tin bên ngoài.
- Giữ chính xác tên người, tổ chức, địa điểm, công ty, đơn vị, thời gian và số liệu quan trọng.
- Ưu tiên thông tin quan trọng nhất thay vì tóm tắt từng đoạn theo tỷ lệ.
- Tóm tắt theo mạch diễn biến tự nhiên của bài viết.
- Văn phong tự nhiên, giống một biên tập viên đang viết bản tin ngắn. Có thể biến đổi câu từ 1 tí để tạo cảm giác hấp dẫn cho câu văn nhưng vẫn giữ được ý chính.
- Sử dụng các từ ngữ hấp dẫn, lôi cuốn và có thể lôi kéo, mời gọi người đọc đọc tiếp tạo cảm giác tò mò, hấp dẫn cho bài báo. Tuy nhiên hấp dẫn nhưng không được phóng đại quá sự thật trong bài.
- Ngắn gọn, dễ đọc nhưng vẫn đủ thông tin để người đọc hiểu nội dung.
- Viết liền mạch, không xuống dòng giữa các câu, không dùng bullet point hoặc đánh số.
- Không dùng các câu mở đầu máy móc như "Bài viết đề cập đến...", "Theo bài báo...", "Nội dung bài viết cho biết...".
- Không sử dụng những câu sáo rỗng hoặc nhận xét không có trong bài.
- Không biến thông tin dự kiến, tin đồn, cáo buộc hoặc nhận định thành sự thật.

LƯỢC BỎ NỘI DUNG KHÔNG CẦN THIẾT:
Bỏ qua hoàn toàn:
- Header, navigation, menu, breadcrumb.
- Footer, copyright, thông tin liên hệ.
- Quảng cáo, banner, popup.
- Cookie/privacy notice.
- Nút chia sẻ, đăng nhập, đăng ký.
- Newsletter.
- Bài viết liên quan/bài viết đề xuất.
- Bình luận.
- Các nội dung không thuộc bài báo.

Đặc biệt, nếu nội dung cookie chiếm phần lớn dữ liệu được trích xuất và phần bài báo thực tế quá ít hoặc không đủ thông tin để tóm tắt có ý nghĩa, chỉ trả về một chuỗi rỗng hoàn toàn, không kèm bất kỳ ký tự hay giải thích nào.

OUTPUT YÊU CẦU:
- Chỉ trả về bản tóm tắt.
- Không có tiêu đề.
- Không có "Tóm tắt:".
- Không bullet point.
- Không đánh số.
- Không Markdown.
- Không JSON.
- Không code block.
- Không giải thích thêm.
- Bắt buộc gồm 3 câu, viết liền mạch, không xuống dòng giữa các câu.
- Nếu bài báo quá ngắn, không có nội dung thực tế hoặc không đủ thông tin để tóm tắt có ý nghĩa, chỉ trả về một chuỗi rỗng hoàn toàn, không kèm bất kỳ ký tự hay giải thích nào.

HÃY CHECK LẠI NỘI DUNG TRƯỚC KHI TRẢ VỀ
1. Có đúng 3 câu không?
2. Có bao quát ý chính không?
3. Có thông tin nào bị bịa hoặc suy diễn không?
4. Có bỏ qua nội dung rác không?
5. Có giữ đúng tên riêng, thời gian và số liệu không?
6. Người đọc chưa mở bài gốc có hiểu được bài báo nói gì không?
7. Văn phong có tự nhiên, hấp dẫn, lôi cuốn và thu hút người đọc không?

Nếu chưa đạt, hãy tự chỉnh sửa trước khi trả về.

TUÂN THỦ NGHIÊM NGẶT CÁC RULE TRÊN. KHÔNG ĐƯỢC PHÁ CÁCH.

Đây là nội dung bài báo cần tóm tắt:
%s`
31 changes: 31 additions & 0 deletions internal/service/retry_fetch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package service

import "sync"

// maxFetchAttempts limits how many cron cycles retry a failed content fetch
// before falling back to sending the article with an empty summary.
// The attempt counter is in-memory only, so it resets on restart.
const maxFetchAttempts = 3

var (
fetchMu sync.Mutex
fetchAttempts = make(map[string]int)
)

// shouldRetryFetch records a failed fetch for the url and reports
// whether it should be retried on the next cron cycle.
func shouldRetryFetch(url string) bool {
fetchMu.Lock()
defer fetchMu.Unlock()

fetchAttempts[url]++
return fetchAttempts[url] < maxFetchAttempts
}

// clearFetchAttempt removes the retry counter after the url is processed.
func clearFetchAttempt(url string) {
fetchMu.Lock()
defer fetchMu.Unlock()

delete(fetchAttempts, url)
}
14 changes: 10 additions & 4 deletions internal/service/send_tele.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,19 @@ package service
import (
"fmt"
"hcmus-news-tele-bot/internal/model"
"html"
"log"
"strconv"
"strings"

"github.com/jackc/pgx/v5/pgxpool"
tele "gopkg.in/telebot.v4"
)

func SendTele(b *tele.Bot, users []string, res model.SummaryResult) {
title := strings.TrimSpace(res.Article.Title)
summary := strings.TrimSpace(res.Summary)
link := res.Article.URL
func SendTele(b *tele.Bot, dbPool *pgxpool.Pool, users []string, res model.SummaryResult) {
title := html.EscapeString(strings.TrimSpace(res.Article.Title))
summary := html.EscapeString(strings.TrimSpace(res.Summary))
link := html.EscapeString(res.Article.URL)

msg := ""

Expand All @@ -33,6 +35,10 @@ func SendTele(b *tele.Bot, users []string, res model.SummaryResult) {
_, err = b.Send(recipient, msg, tele.ModeHTML)
if err != nil {
log.Printf("Error sending message to user %s: %v\n", uID, err)

if isUserUnreachable(err) {
autoUnsubscribe(dbPool, uID)
}
}
}
}
21 changes: 5 additions & 16 deletions internal/service/summarize.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"hcmus-news-tele-bot/config"
"hcmus-news-tele-bot/internal/model"
"log"
"os"
Expand All @@ -19,19 +20,14 @@ var (
initErr error
)

var geminiModels = []string{
"gemini-3.5-flash",
"gemini-2.5-flash",
"gemini-3.1-flash-lite",
}

func isGeminiFallbackError(err error) bool {
var apiErr genai.APIError
if !errors.As(err, &apiErr) {
return false
}

return apiErr.Code == 429 || apiErr.Code == 503
// 429 (Too Many Requests), 503 (Service Unavailable), 500 (Internal Server Error)
return apiErr.Code == 429 || apiErr.Code == 503 || apiErr.Code == 500
}

func geminiErrorCode(err error) int {
Expand Down Expand Up @@ -69,6 +65,7 @@ func getGeminiClient(ctx context.Context) (*genai.Client, error) {

func SummarizeContentWithGemini(content string) (model.SummaryResult, error) {
ctx := context.Background()
geminiModels := config.Models()

client, err := getGeminiClient(ctx)
if err != nil {
Expand All @@ -79,15 +76,7 @@ func SummarizeContentWithGemini(content string) (model.SummaryResult, error) {
Temperature: genai.Ptr[float32](0.4),
}

prompt := fmt.Sprintf(`
Bạn là một biên tập viên tóm tắt tin tức chuyên nghiệp. Nhiệm vụ của bạn là:
- Tóm tắt nội dung tin tức sau không vượt quá 3 dòng -> người dùng sẽ cảm thấy quá dài và không đọc (Bắt buộc - Key).
- Văn phong tóm tắt phải tự nhiên, không quá máy móc, bám sát nội dung bài viết.
- Phải đi qua đủ hết nội dung của trang web, tóm tắt lại đầy đủ -> người dùng chưa cần ấn vào link vẫn có thể nắm được sơ qua nội dung chính của bài viết.
- Chọn những dòng quan trọng/hấp dẫn để tóm tắt -> người dùng hứng thú -> vào link đọc tiếp.
- Không cần chào hỏi, vô thẳng nội dung chính, không cần nói thêm gì khác.
- Nếu tóm tắt xong, nội dung có câu: Trang web này sử dụng cookie, thì không ghi đoạn này, nếu không đủ nội dung thì để rỗng.
Nội dung bài viết như sau: %s`, content)
prompt := fmt.Sprintf(summarizePrompt, content)

var resp *genai.GenerateContentResponse

Expand Down
15 changes: 14 additions & 1 deletion internal/service/worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,24 @@ func Worker(id int, jobs <-chan model.SummaryJob, results chan<- model.SummaryRe

content, err := GetContentFromURL(job.Article.URL)
if err != nil {
results <- emptyResult()
log.Printf("Error getting content while fetching URL %s: %v", job.Article.URL, err)

if shouldRetryFetch(job.Article.URL) {
results <- model.SummaryResult{
Article: job.Article,
Category: job.Category,
Retryable: true,
}
continue
}

clearFetchAttempt(job.Article.URL)
results <- emptyResult()
continue
}

clearFetchAttempt(job.Article.URL)

// content is empty -> no need to summarize, just return empty summary
// ("", err) or ("", nil)
if content == "" {
Expand Down
Loading