diff --git a/cmd/server/main.go b/cmd/server/main.go
index 23c43bb..aa5a952 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -106,6 +106,8 @@ func main() {
r.Get("/shop", ecoH.Shop)
r.Post("/shop/buy-card", ecoH.BuyCard)
r.Post("/shop/select-card", ecoH.SelectCard)
+ r.Get("/chat-packs", ecoH.ChatPacks)
+ r.Post("/shop/buy-chat-pack", ecoH.BuyChatPack)
r.Post("/shop/purchase", ecoH.Purchase)
r.Post("/rewards/daily", ecoH.Daily)
r.Post("/rewards/ad", ecoH.AdReward)
diff --git a/internal/admin/admin.go b/internal/admin/admin.go
index 4ca6eb7..8d25fac 100644
--- a/internal/admin/admin.go
+++ b/internal/admin/admin.go
@@ -51,6 +51,11 @@ func (h *Handler) Routes(user, pass string) http.Handler {
r.Post("/users/coins", h.adjustCoins)
r.Post("/users/grant", h.grantToUser)
r.Post("/season/reset", h.resetSeason)
+ r.Get("/chat", h.chatAdmin)
+ r.Post("/chat/pack/add", h.chatPackAdd)
+ r.Post("/chat/pack/delete", h.chatPackDelete)
+ r.Post("/chat/message/add", h.chatMessageAdd)
+ r.Post("/chat/message/delete", h.chatMessageDelete)
return r
}
@@ -438,6 +443,103 @@ func (h *Handler) grantToUser(w http.ResponseWriter, r *http.Request) {
}
// rows یک کوئری را به []map[string]any تبدیل میکند (برای رندرِ عمومیِ جدولها).
+// --- مدیریتِ بستههای چت و پیامها ---
+
+func (h *Handler) chatAdmin(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ packs := h.rows(ctx,
+ `SELECT id,title,kind,price_coins,vip,sort,enabled FROM chat_packs ORDER BY sort`,
+ "id", "title", "kind", "price_coins", "vip", "sort", "enabled")
+ // پیامهای هر بسته را زیرِ همان بسته قرار میدهیم.
+ for _, p := range packs {
+ id, _ := p["id"].(string)
+ p["messages"] = h.messagesOf(ctx, id)
+ }
+ h.render(w, "chat.html", map[string]any{
+ "Nav": "chat",
+ "Packs": packs,
+ "Saved": r.URL.Query().Get("saved") == "1",
+ })
+}
+
+func (h *Handler) messagesOf(ctx context.Context, packID string) []map[string]any {
+ rs, err := h.db.QueryContext(ctx,
+ `SELECT id,body,sort FROM chat_messages WHERE pack_id=? ORDER BY sort,id`, packID)
+ if err != nil {
+ return nil
+ }
+ defer rs.Close()
+ var out []map[string]any
+ for rs.Next() {
+ var id, sort int
+ var body string
+ if err := rs.Scan(&id, &body, &sort); err != nil {
+ return out
+ }
+ out = append(out, map[string]any{"id": id, "body": body, "sort": sort})
+ }
+ return out
+}
+
+func (h *Handler) chatPackAdd(w http.ResponseWriter, r *http.Request) {
+ _ = r.ParseForm()
+ id := r.FormValue("id")
+ if id == "" {
+ http.Error(w, "id required", http.StatusBadRequest)
+ return
+ }
+ num := func(k string) int { v, _ := strconv.Atoi(r.FormValue(k)); return v }
+ vip := 0
+ if r.FormValue("vip") == "on" {
+ vip = 1
+ }
+ kind := r.FormValue("kind")
+ if kind != "emoji" {
+ kind = "text"
+ }
+ _, err := h.db.ExecContext(r.Context(),
+ `INSERT OR REPLACE INTO chat_packs (id,title,kind,price_coins,vip,sort,enabled) VALUES (?,?,?,?,?,?,1)`,
+ id, r.FormValue("title"), kind, num("price_coins"), vip, num("sort"))
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, "/admin/chat?saved=1", http.StatusSeeOther)
+}
+
+func (h *Handler) chatPackDelete(w http.ResponseWriter, r *http.Request) {
+ _ = r.ParseForm()
+ id := r.FormValue("id")
+ _, _ = h.db.ExecContext(r.Context(), `DELETE FROM chat_messages WHERE pack_id=?`, id)
+ _, _ = h.db.ExecContext(r.Context(), `DELETE FROM chat_packs WHERE id=?`, id)
+ http.Redirect(w, r, "/admin/chat?saved=1", http.StatusSeeOther)
+}
+
+func (h *Handler) chatMessageAdd(w http.ResponseWriter, r *http.Request) {
+ _ = r.ParseForm()
+ pack := r.FormValue("pack_id")
+ body := r.FormValue("body")
+ if pack == "" || body == "" {
+ http.Redirect(w, r, "/admin/chat", http.StatusSeeOther)
+ return
+ }
+ sort, _ := strconv.Atoi(r.FormValue("sort"))
+ _, err := h.db.ExecContext(r.Context(),
+ `INSERT INTO chat_messages (pack_id,body,sort) VALUES (?,?,?)`, pack, body, sort)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, "/admin/chat?saved=1", http.StatusSeeOther)
+}
+
+func (h *Handler) chatMessageDelete(w http.ResponseWriter, r *http.Request) {
+ _ = r.ParseForm()
+ id, _ := strconv.Atoi(r.FormValue("id"))
+ _, _ = h.db.ExecContext(r.Context(), `DELETE FROM chat_messages WHERE id=?`, id)
+ http.Redirect(w, r, "/admin/chat?saved=1", http.StatusSeeOther)
+}
+
func (h *Handler) rows(ctx context.Context, query string, cols ...string) []map[string]any {
rs, err := h.db.QueryContext(ctx, query)
if err != nil {
diff --git a/internal/admin/templates/chat.html b/internal/admin/templates/chat.html
new file mode 100644
index 0000000..ab36c4a
--- /dev/null
+++ b/internal/admin/templates/chat.html
@@ -0,0 +1,71 @@
+
+
+{{template "head" .}}
+
+{{template "nav" .}}
+
+
بستههای چت
+ {{if .Saved}}
انجام شد ✓
{{end}}
+
+
+ هر بسته شاملِ چند پیامِ آماده یا شکلک است. بسته با قیمتِ ۰ و بدونِ VIP برای
+ همه رایگان است؛ تیکِ VIP یعنی برای کاربرانِ VIP رایگان (و برای بقیه با قیمتِ سکه).
+
+
+
افزودن / ویرایش بسته
+
+
شناسه
عنوان
نوع
قیمت (سکه)
VIP رایگان
ترتیب
+ {{range .Packs}}
+
+ {{end}}
+
+
+
+ {{range .Packs}}
+
پیامهای «{{.title}}» ({{.id}})
+
+
متن / شکلک
ترتیب
+ {{range .messages}}
+
+ {{end}}
+
+
+ {{end}}
+
+
+
diff --git a/internal/admin/templates/layout.html b/internal/admin/templates/layout.html
index b335158..53c97d5 100644
--- a/internal/admin/templates/layout.html
+++ b/internal/admin/templates/layout.html
@@ -33,6 +33,7 @@
{{end}}
diff --git a/internal/economy/chat.go b/internal/economy/chat.go
new file mode 100644
index 0000000..9fc6b3a
--- /dev/null
+++ b/internal/economy/chat.go
@@ -0,0 +1,129 @@
+package economy
+
+import "context"
+
+// ChatPack یک بستهی پیام/شکلکِ چت. Owned یعنی کاربر میتواند از آن استفاده کند
+// (رایگان، یا VIP برای بستهی vip، یا خریداریشده).
+type ChatPack struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Kind string `json:"kind"` // text | emoji
+ PriceCoins int64 `json:"price_coins"`
+ VIP bool `json:"vip"` // برای VIP رایگان است
+ Owned bool `json:"owned"`
+ Messages []string `json:"messages"`
+}
+
+// ChatPacks بستههای فعال را همراه پیامها و وضعیتِ مالکیتِ کاربر برمیگرداند.
+func (s *Service) ChatPacks(ctx context.Context, userID int64) ([]ChatPack, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT id, title, kind, price_coins, vip FROM chat_packs WHERE enabled = 1 ORDER BY sort`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var packs []ChatPack
+ for rows.Next() {
+ var p ChatPack
+ var vip int
+ if err := rows.Scan(&p.ID, &p.Title, &p.Kind, &p.PriceCoins, &vip); err != nil {
+ return nil, err
+ }
+ p.VIP = vip == 1
+ packs = append(packs, p)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+
+ owned, err := s.ownedChatPacks(ctx, userID)
+ if err != nil {
+ return nil, err
+ }
+ isVIP := s.isVIP(ctx, userID)
+
+ for i := range packs {
+ p := &packs[i]
+ // رایگان (بدون قیمت و غیرِ vip)، یا VIP برای بستهی vip، یا خریداریشده.
+ p.Owned = (p.PriceCoins == 0 && !p.VIP) || (p.VIP && isVIP) || owned[p.ID]
+ msgs, err := s.packMessages(ctx, p.ID)
+ if err != nil {
+ return nil, err
+ }
+ p.Messages = msgs
+ }
+ return packs, nil
+}
+
+func (s *Service) packMessages(ctx context.Context, packID string) ([]string, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT body FROM chat_messages WHERE pack_id = ? ORDER BY sort, id`, packID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ msgs := []string{}
+ for rows.Next() {
+ var b string
+ if err := rows.Scan(&b); err != nil {
+ return nil, err
+ }
+ msgs = append(msgs, b)
+ }
+ return msgs, rows.Err()
+}
+
+func (s *Service) ownedChatPacks(ctx context.Context, userID int64) (map[string]bool, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT pack_id FROM user_chat_packs WHERE user_id = ?`, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ owned := map[string]bool{}
+ for rows.Next() {
+ var id string
+ if err := rows.Scan(&id); err != nil {
+ return nil, err
+ }
+ owned[id] = true
+ }
+ return owned, rows.Err()
+}
+
+// BuyChatPack یک بستهی چت را با سکه باز میکند. بستههای رایگان/قبلاًمالک خطا
+// میدهند؛ بستهی VIP برای کاربرِ VIP خودبهخود در دسترس است (نیازی به خرید نیست).
+func (s *Service) BuyChatPack(ctx context.Context, userID int64, packID string) error {
+ var price int64
+ var vip, enabled int
+ err := s.db.QueryRowContext(ctx,
+ `SELECT price_coins, vip, enabled FROM chat_packs WHERE id = ?`, packID).
+ Scan(&price, &vip, &enabled)
+ if err != nil || enabled != 1 {
+ return ErrNotFound
+ }
+
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ var exists int
+ _ = tx.QueryRowContext(ctx,
+ `SELECT 1 FROM user_chat_packs WHERE user_id = ? AND pack_id = ?`, userID, packID).Scan(&exists)
+ if exists == 1 {
+ return ErrAlreadyOwned
+ }
+ if price > 0 {
+ if err := adjustTx(ctx, tx, userID, CurrencyCoin, -price, "buy_chat_pack", packID); err != nil {
+ return err
+ }
+ }
+ if _, err := tx.ExecContext(ctx,
+ `INSERT INTO user_chat_packs (user_id, pack_id) VALUES (?, ?)`, userID, packID); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
diff --git a/internal/economy/handler.go b/internal/economy/handler.go
index 07f25ac..82ad405 100644
--- a/internal/economy/handler.go
+++ b/internal/economy/handler.go
@@ -87,6 +87,49 @@ func (h *Handler) BuyCard(w http.ResponseWriter, r *http.Request) {
}
}
+// ChatPacks — GET /api/chat-packs (بستههای پیام/شکلک + مالکیتِ کاربر)
+func (h *Handler) ChatPacks(w http.ResponseWriter, r *http.Request) {
+ id, ok := uid(r)
+ if !ok {
+ httpx.Error(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+ packs, err := h.svc.ChatPacks(r.Context(), id)
+ if err != nil {
+ httpx.Error(w, http.StatusInternalServerError, "server error")
+ return
+ }
+ httpx.JSON(w, http.StatusOK, map[string]any{"packs": packs})
+}
+
+// BuyChatPack — POST /api/shop/buy-chat-pack
+func (h *Handler) BuyChatPack(w http.ResponseWriter, r *http.Request) {
+ id, ok := uid(r)
+ if !ok {
+ httpx.Error(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+ var req struct {
+ PackID string `json:"pack_id"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ httpx.Error(w, http.StatusBadRequest, "invalid body")
+ return
+ }
+ switch err := h.svc.BuyChatPack(r.Context(), id, req.PackID); {
+ case err == nil:
+ httpx.JSON(w, http.StatusOK, map[string]string{"message": "ok"})
+ case errors.Is(err, ErrNotFound):
+ httpx.Error(w, http.StatusNotFound, "pack not found")
+ case errors.Is(err, ErrAlreadyOwned):
+ httpx.Error(w, http.StatusConflict, "already owned")
+ case errors.Is(err, ErrInsufficient):
+ httpx.Error(w, http.StatusPaymentRequired, "insufficient coins")
+ default:
+ httpx.Error(w, http.StatusInternalServerError, "server error")
+ }
+}
+
// SelectCard — POST /api/shop/select-card
func (h *Handler) SelectCard(w http.ResponseWriter, r *http.Request) {
id, ok := uid(r)
diff --git a/internal/store/migrations/008_chat.sql b/internal/store/migrations/008_chat.sql
new file mode 100644
index 0000000..a7a612b
--- /dev/null
+++ b/internal/store/migrations/008_chat.sql
@@ -0,0 +1,58 @@
+-- بستههای چت (پیامهای آماده و شکلکها). برخی رایگان، برخی با سکه یا ویژهی VIP.
+CREATE TABLE IF NOT EXISTS chat_packs (
+ id TEXT PRIMARY KEY,
+ title TEXT NOT NULL,
+ kind TEXT NOT NULL DEFAULT 'text', -- text | emoji
+ price_coins INTEGER NOT NULL DEFAULT 0, -- ۰ یعنی رایگان (مگر vip)
+ vip INTEGER NOT NULL DEFAULT 0, -- ۱ یعنی برای VIP رایگان است
+ sort INTEGER NOT NULL DEFAULT 0,
+ enabled INTEGER NOT NULL DEFAULT 1
+);
+
+CREATE TABLE IF NOT EXISTS chat_messages (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ pack_id TEXT NOT NULL,
+ body TEXT NOT NULL, -- متنِ پیام یا کاراکترِ شکلک
+ sort INTEGER NOT NULL DEFAULT 0
+);
+CREATE INDEX IF NOT EXISTS idx_chat_messages_pack ON chat_messages(pack_id);
+
+-- مالکیتِ بستههای خریداریشده توسط کاربر.
+CREATE TABLE IF NOT EXISTS user_chat_packs (
+ user_id INTEGER NOT NULL,
+ pack_id TEXT NOT NULL,
+ PRIMARY KEY (user_id, pack_id)
+);
+
+INSERT OR IGNORE INTO chat_packs (id, title, kind, price_coins, vip, sort) VALUES
+ ('general', 'عمومی', 'text', 0, 0, 1),
+ ('friendly', 'دوستانه', 'text', 2500, 0, 2),
+ ('kolkol', 'کل کل', 'text', 5500, 0, 3),
+ ('hakem', 'حکم بازها', 'text', 6500, 0, 4),
+ ('emoji', 'شکلک', 'emoji', 1000, 1, 5);
+
+INSERT OR IGNORE INTO chat_messages (pack_id, body, sort) VALUES
+ -- عمومی (رایگان)
+ ('general','سلام',1),('general','ساکت',2),('general','بعد بازی بیا اتاق چت!',3),
+ ('general','عجب شانسی!',4),('general','با شما نبودم',5),('general','رحم کن',6),
+ ('general','شرمنده',7),('general','حواسم نبود',8),('general','ای بابا',9),
+ ('general','آخیش',10),('general','نت ضعیفه',11),('general','بیا دیگه',12),
+ -- دوستانه
+ ('friendly','به به بروبچ با صفا',1),('friendly','دم شما گرم',2),('friendly','خیلی مخلصیم',3),
+ ('friendly','به مولا اگه بذارم',4),('friendly','آقایی',5),('friendly','با خاک پاتیم',6),
+ ('friendly','فدایی داری',7),('friendly','حال و احوال؟',8),('friendly','سلاطین',9),
+ ('friendly','آبجی خودمی',10),('friendly','شماره بدی زنگ میزنم',11),
+ -- کل کل
+ ('kolkol','اینکاره نیستی',1),('kolkol','ها ها ها ها',2),('kolkol','خخخخخ...',3),
+ ('kolkol','هنوز زوده برات',4),('kolkol','حالتو میگیرم',5),('kolkol','شانستو ببین',6),
+ ('kolkol','شاخ نسو',7),('kolkol','بی بی بودی!',8),('kolkol','برو کنار بذار باد بیاد',9),
+ ('kolkol','ناخونات نشکنه جیگر',10),('kolkol','چرا میخندی؟',11),('kolkol','یاد گرفتی؟',12),
+ -- حکم بازها
+ ('hakem','کارت تمومه',1),('hakem','هیس بابا',2),('hakem','کریم بخدا مسلمون نیستی',3),
+ ('hakem','حاکم فقط خودم',4),('hakem','کت نشین! با جواب میخوام',5),('hakem','دست خوبم نداریم',6),
+ ('hakem','کمه!',7),('hakem','حواست کجاست؟',8),('hakem','شیرزن',9),
+ ('hakem','خیلی عقبید',10),('hakem','برادرمی',11),
+ -- شکلک (emoji)
+ ('emoji','😎',1),('emoji','😉',2),('emoji','😌',3),('emoji','😄',4),('emoji','😆',5),
+ ('emoji','😡',6),('emoji','😍',7),('emoji','🙄',8),('emoji','😭',9),('emoji','👋',10),
+ ('emoji','🤝',11),('emoji','🤔',12),('emoji','😴',13),('emoji','🤑',14),('emoji','😵',15),('emoji','🥳',16);