/search 哥斯拉", nil
+ }
+
+ keyword := strings.Join(args, " ")
+ var results []model.Media
+ err := s.repo.DB.Where("title LIKE ?", "%"+keyword+"%").
+ Order("year DESC").Limit(8).
+ Find(&results).Error
+ if err != nil {
+ return "", err
+ }
+
+ if len(results) == 0 {
+ return fmt.Sprintf("未找到与 %s 相关的媒体", keyword), nil
+ }
+
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("搜索: %s\n\n", keyword))
+ for i, m := range results {
+ year := ""
+ if m.Year > 0 {
+ year = fmt.Sprintf(" (%d)", m.Year)
+ }
+ ep := ""
+ if m.SeasonNum > 0 && m.EpisodeNum > 0 {
+ ep = fmt.Sprintf(" S%02dE%02d", m.SeasonNum, m.EpisodeNum)
+ }
+ sb.WriteString(fmt.Sprintf("%d. %s%s%s — %s\n", i+1, m.Title, year, ep, formatSize(m.SizeBytes)))
+ }
+
+ return sb.String(), nil
+}
+
+// cmdDownloads 处理 /downloads 命令。
+func (s *TelegramBotService) cmdDownloads(ctx context.Context) (string, error) {
+ type Row struct {
+ URL string
+ Status string
+ }
+ var rows []Row
+ if err := s.repo.DB.Raw(
+ "SELECT url, COALESCE(status,'unknown') as status FROM download_tasks ORDER BY created_at DESC LIMIT 8",
+ ).Scan(&rows).Error; err != nil {
+ return "", err
+ }
+
+ if len(rows) == 0 {
+ return "当前没有下载任务。", nil
+ }
+
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("下载任务 (%d)\n\n", len(rows)))
+ for _, r := range rows {
+ icon := "⏳"
+ switch r.Status {
+ case "completed":
+ icon = "✅"
+ case "downloading":
+ icon = "📥"
+ case "error":
+ icon = "❌"
+ }
+ // 截取文件名
+ name := r.URL
+ if idx := strings.LastIndex(name, "/"); idx >= 0 {
+ name = name[idx+1:]
+ }
+ if len(name) > 60 {
+ name = name[:57] + "..."
+ }
+ sb.WriteString(fmt.Sprintf("%s %s\n", icon, name))
+ }
+
+ return sb.String(), nil
+}
+
+// cmdStats 处理 /stats 命令。
+func (s *TelegramBotService) cmdStats(ctx context.Context) (string, error) {
+ var totalMedia int64
+ s.repo.DB.Model(&model.Media{}).Count(&totalMedia)
+
+ var totalSize int64
+ s.repo.DB.Raw("SELECT COALESCE(SUM(size_bytes), 0) FROM media").Scan(&totalSize)
+
+ type LibStat struct {
+ Name string
+ Type string
+ Count int64
+ }
+ var libs []LibStat
+ s.repo.DB.Raw(
+ "SELECT l.name, l.type, COUNT(m.id) as count FROM libraries l LEFT JOIN media m ON m.library_id = l.id GROUP BY l.id ORDER BY count DESC",
+ ).Scan(&libs)
+
+ var sb strings.Builder
+ sb.WriteString("媒体库统计\n\n")
+ sb.WriteString(fmt.Sprintf("📚 总数: %d\n", totalMedia))
+ sb.WriteString(fmt.Sprintf("💾 大小: %s\n", formatSize(totalSize)))
+
+ if len(libs) > 0 {
+ sb.WriteString("\n各库分布:\n")
+ for _, l := range libs {
+ icon := "🎬"
+ switch l.Type {
+ case "tv":
+ icon = "📺"
+ case "anime":
+ icon = "🍥"
+ case "music":
+ icon = "🎵"
+ }
+ sb.WriteString(fmt.Sprintf("%s %s: %d\n", icon, l.Name, l.Count))
+ }
+ }
+
+ return sb.String(), nil
+}
+
+// ── Polling ──
+
+// StartPolling 为所有已启用的 Telegram 通知渠道启动长轮询。
+func (s *TelegramBotService) StartPolling(ctx context.Context) {
+ channels, err := s.repo.NotifyChannel.ListByType(ctx, "telegram")
+ if err != nil {
+ s.log.Error("failed to list telegram channels for polling", zap.Error(err))
+ return
+ }
+
+ for _, ch := range channels {
+ if !ch.Enabled {
+ continue
+ }
+ configStr := ch.Config
+ if s.crypto != nil && configStr != "" {
+ configStr = s.crypto.Decrypt(configStr)
+ }
+ var cfg map[string]string
+ if err := json.Unmarshal([]byte(configStr), &cfg); err != nil {
+ continue
+ }
+ botToken := cfg["bot_token"]
+ if botToken == "" {
+ continue
+ }
+
+ s.pollingMu.Lock()
+ if _, running := s.pollingCancel[botToken]; running {
+ s.pollingMu.Unlock()
+ continue
+ }
+ pollCtx, cancel := context.WithCancel(context.Background())
+ s.pollingCancel[botToken] = cancel
+ s.pollingMu.Unlock()
+
+ go s.pollLoop(pollCtx, botToken)
+ s.log.Info("started telegram polling", zap.String("channel", ch.Name))
+ }
+}
+
+// StopPolling 停止所有 Telegram 长轮询。
+func (s *TelegramBotService) StopPolling() {
+ s.pollingMu.Lock()
+ defer s.pollingMu.Unlock()
+ for token, cancel := range s.pollingCancel {
+ cancel()
+ delete(s.pollingCancel, token)
+ }
+ s.log.Info("telegram polling stopped")
+}
+
+// pollLoop 对单个 Bot Token 执行长轮询。
+func (s *TelegramBotService) pollLoop(ctx context.Context, botToken string) {
+ var offset int64 = 0
+ pollURL := fmt.Sprintf("https://api.telegram.org/bot%s/getUpdates", botToken)
+ client := &http.Client{Timeout: 45 * time.Second}
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ }
+
+ reqBody, _ := json.Marshal(map[string]interface{}{
+ "offset": offset,
+ "timeout": 30,
+ })
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, pollURL, bytes.NewReader(reqBody))
+ if err != nil {
+ time.Sleep(5 * time.Second)
+ continue
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ time.Sleep(5 * time.Second)
+ continue
+ }
+ respBody, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+
+ var result struct {
+ OK bool `json:"ok"`
+ Result []TelegramUpdate `json:"result"`
+ }
+ if err := json.Unmarshal(respBody, &result); err != nil || !result.OK {
+ time.Sleep(3 * time.Second)
+ continue
+ }
+
+ for _, upd := range result.Result {
+ if upd.UpdateID >= int(offset) {
+ offset = int64(upd.UpdateID) + 1
+ }
+ if upd.Message == nil || upd.Message.Text == "" {
+ continue
+ }
+ go func(u TelegramUpdate) {
+ raw, _ := json.Marshal(u)
+ _ = s.HandleWebhook(context.Background(), raw)
+ }(upd)
+ }
+ }
+}
+
+// ── Message Sending ──
+
+// reply 通过 Telegram Bot API 发送回复消息。
+func (s *TelegramBotService) reply(ctx context.Context, channel *model.NotifyChannel, chatID int, text string) error {
+ botToken := ""
+ if channel != nil {
+ configStr := channel.Config
+ if s.crypto != nil && configStr != "" {
+ configStr = s.crypto.Decrypt(configStr)
+ }
+ var cfg map[string]string
+ if err := json.Unmarshal([]byte(configStr), &cfg); err == nil {
+ botToken = cfg["bot_token"]
+ }
+ }
+ if botToken == "" {
+ return fmt.Errorf("bot_token not configured")
+ }
+
+ payload := map[string]interface{}{
+ "chat_id": strconv.Itoa(chatID),
+ "text": text,
+ "parse_mode": "HTML",
+ }
+ body, _ := json.Marshal(payload)
+
+ apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ client := &http.Client{Timeout: 15 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode >= 400 {
+ respBody, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("telegram api error %d: %s", resp.StatusCode, string(respBody))
+ }
+ return nil
+}
+
+// findChannelByChatID 根据 chat_id 查找已配置的通知渠道。
+func (s *TelegramBotService) findChannelByChatID(ctx context.Context, chatID int) *model.NotifyChannel {
+ channels, err := s.repo.NotifyChannel.ListByType(ctx, "telegram")
+ if err != nil {
+ return nil
+ }
+ target := strconv.Itoa(chatID)
+ for _, ch := range channels {
+ if !ch.Enabled {
+ continue
+ }
+ configStr := ch.Config
+ if s.crypto != nil && configStr != "" {
+ configStr = s.crypto.Decrypt(configStr)
+ }
+ var cfg map[string]string
+ json.Unmarshal([]byte(configStr), &cfg)
+ if cfg["chat_id"] == target {
+ return &ch
+ }
+ }
+ return nil
+}
+
+// ── Webhook Management ──
+
+// SetWebhook 注册 Telegram Bot Webhook URL。
+func (s *TelegramBotService) SetWebhook(ctx context.Context, botToken, webhookURL string) error {
+ payload, _ := json.Marshal(map[string]interface{}{
+ "url": webhookURL,
+ "allowed_updates": []string{"message"},
+ })
+ req, _ := http.NewRequestWithContext(ctx, http.MethodPost,
+ fmt.Sprintf("https://api.telegram.org/bot%s/setWebhook", botToken),
+ bytes.NewReader(payload))
+ req.Header.Set("Content-Type", "application/json")
+
+ client := &http.Client{Timeout: 15 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode >= 400 {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("setWebhook failed: %s", string(body))
+ }
+ return nil
+}
+
+// GetWebhookInfo 获取 Webhook 配置信息。
+func (s *TelegramBotService) GetWebhookInfo(ctx context.Context, botToken string) (map[string]interface{}, error) {
+ resp, err := (&http.Client{Timeout: 10 * time.Second}).Get(
+ fmt.Sprintf("https://api.telegram.org/bot%s/getWebhookInfo", botToken),
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var result map[string]interface{}
+ body, _ := io.ReadAll(resp.Body)
+ json.Unmarshal(body, &result)
+ return result, nil
+}
+
+// formatSize 格式化字节数为可读字符串。
+func formatSize(bytes int64) string {
+ if bytes <= 0 {
+ return "0 B"
+ }
+ units := []string{"B", "KB", "MB", "GB", "TB"}
+ v := float64(bytes)
+ i := 0
+ for v >= 1024 && i < len(units)-1 {
+ v /= 1024
+ i++
+ }
+ if i == 0 {
+ return fmt.Sprintf("%.0f %s", v, units[i])
+ }
+ return fmt.Sprintf("%.1f %s", v, units[i])
+}
diff --git a/web/src/api/notify_channels.ts b/web/src/api/notify_channels.ts
index 503b2da..5359cb9 100644
--- a/web/src/api/notify_channels.ts
+++ b/web/src/api/notify_channels.ts
@@ -4,7 +4,7 @@ import type { NotifyChannel } from '../types'
// Payload accepted by create / update. `events` and `enabled` are optional.
export interface NotifyChannelInput {
name: string
- channel_type: NotifyChannel['channel_type']
+ type: NotifyChannel['type']
config: Record| 服务 | 密钥 | @@ -63,7 +63,7 @@ export function APIConfigsPanel() { ) : (||
|---|---|---|---|
|
{item.provider} @@ -75,7 +75,7 @@ export function APIConfigsPanel() { {item.has_key ? ( {item.masked_key} ) : ( - 未配置 + 未配置 )} |
@@ -93,7 +93,7 @@ export function APIConfigsPanel() {
| ||
| {u.username} | {u.role} | @@ -171,7 +171,7 @@ function UsersPanel() { |