diff --git a/cmd/cc-connect/cron.go b/cmd/cc-connect/cron.go index c983da7..b266207 100644 --- a/cmd/cc-connect/cron.go +++ b/cmd/cc-connect/cron.go @@ -26,6 +26,8 @@ func runCron(args []string) { runCronList(args[1:]) case "edit": runCronEdit(args[1:]) + case "info": + runCronInfo(args[1:]) case "del", "delete", "rm", "remove": runCronDel(args[1:]) case "--help", "-h", "help": @@ -313,6 +315,98 @@ func runCronDel(args []string) { fmt.Printf("Cron job %s deleted.\n", id) } +func runCronInfo(args []string) { + var dataDir, id, field string + + for i := 0; i < len(args); i++ { + switch args[i] { + case "--data-dir": + if i+1 < len(args) { + i++ + dataDir = args[i] + } + default: + if id == "" { + id = args[i] + } else if field == "" { + field = args[i] + } + } + } + + if id == "" { + fmt.Fprintln(os.Stderr, "Error: job ID is required") + fmt.Fprintln(os.Stderr, "Usage: cc-connect cron info [field]") + fmt.Fprintln(os.Stderr, "Use 'cc-connect cron list' to see all task IDs.") + os.Exit(1) + } + + sockPath := resolveSocketPath(dataDir) + if _, err := os.Stat(sockPath); os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "Error: cc-connect is not running (socket not found: %s)\n", sockPath) + os.Exit(1) + } + + client := &http.Client{ + Transport: &http.Transport{ + DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", sockPath) + }, + }, + } + + resp, err := client.Get("http://unix/cron/info?id=" + id) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode == http.StatusNotFound { + fmt.Fprintf(os.Stderr, "Error: job '%s' not found\n", id) + os.Exit(1) + } + if resp.StatusCode != http.StatusOK { + fmt.Fprintf(os.Stderr, "Error: %s\n", strings.TrimSpace(string(body))) + os.Exit(1) + } + + // If field specified, extract and print only that field + if field != "" { + var result map[string]any + if err := json.Unmarshal(body, &result); err != nil { + fmt.Fprintf(os.Stderr, "Error: invalid JSON response: %v\n", err) + os.Exit(1) + } + val, ok := result[field] + if !ok { + fmt.Fprintf(os.Stderr, "Error: field '%s' not found\n", field) + fmt.Fprintln(os.Stderr, "Available fields:") + for k := range result { + fmt.Fprintf(os.Stderr, " - %s\n", k) + } + os.Exit(1) + } + // Output field value (string directly, otherwise JSON formatted) + if s, ok := val.(string); ok { + fmt.Println(s) + } else { + data, _ := json.MarshalIndent(val, "", " ") + fmt.Println(string(data)) + } + return + } + + // Pretty-print full JSON + var prettyJSON bytes.Buffer + if err := json.Indent(&prettyJSON, body, "", " "); err != nil { + fmt.Fprintf(os.Stderr, "Error: invalid JSON response: %v\n", err) + os.Exit(1) + } + fmt.Println(prettyJSON.String()) +} + func runCronEdit(args []string) { var dataDir string var id, field string @@ -424,6 +518,8 @@ Commands: add Create a new scheduled task list List all scheduled tasks edit Edit a scheduled task field + info [field] Show detailed info of a scheduled task + (optionally filter to a single field) del Delete a scheduled task Run 'cc-connect cron --help' for details.`) diff --git a/core/api.go b/core/api.go index ae50eaa..8755a0b 100644 --- a/core/api.go +++ b/core/api.go @@ -65,6 +65,7 @@ func NewAPIServer(dataDir string) (*APIServer, error) { s.mux.HandleFunc("/sessions", s.handleSessions) s.mux.HandleFunc("/cron/add", s.handleCronAdd) s.mux.HandleFunc("/cron/list", s.handleCronList) + s.mux.HandleFunc("/cron/info", s.handleCronInfo) s.mux.HandleFunc("/cron/edit", s.handleCronEdit) s.mux.HandleFunc("/cron/del", s.handleCronDel) s.mux.HandleFunc("/relay/send", s.handleRelaySend) @@ -351,6 +352,31 @@ func (s *APIServer) handleCronDel(w http.ResponseWriter, r *http.Request) { } } +func (s *APIServer) handleCronInfo(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "GET only", http.StatusMethodNotAllowed) + return + } + if s.cron == nil { + http.Error(w, "cron scheduler not available", http.StatusServiceUnavailable) + return + } + + id := r.URL.Query().Get("id") + if id == "" { + http.Error(w, "id is required", http.StatusBadRequest) + return + } + + job := s.cron.store.Get(id) + if job == nil { + http.Error(w, fmt.Sprintf("job %q not found", id), http.StatusNotFound) + return + } + + apiJSON(w, http.StatusOK, job) +} + func (s *APIServer) handleCronEdit(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "POST only", http.StatusMethodNotAllowed) diff --git a/platform/discord/discord.go b/platform/discord/discord.go index 52fde34..4452762 100644 --- a/platform/discord/discord.go +++ b/platform/discord/discord.go @@ -796,6 +796,62 @@ func (p *Platform) SendImage(ctx context.Context, rctx any, img core.ImageAttach } } +func (p *Platform) SendFile(ctx context.Context, rctx any, file core.FileAttachment) error { + name := file.FileName + if name == "" { + name = "attachment" + } + + newFile := func() *discordgo.File { + return &discordgo.File{ + Name: name, + ContentType: file.MimeType, + Reader: bytes.NewReader(file.Data), + } + } + + switch rc := rctx.(type) { + case *interactionReplyCtx: + rc.mu.Lock() + first := !rc.firstDone + if first { + rc.firstDone = true + } + rc.mu.Unlock() + + var err error + if first { + _, err = p.session.InteractionResponseEdit(rc.interaction, &discordgo.WebhookEdit{ + Files: []*discordgo.File{newFile()}, + }) + } else { + _, err = p.session.FollowupMessageCreate(rc.interaction, true, &discordgo.WebhookParams{ + Files: []*discordgo.File{newFile()}, + }) + } + if err != nil { + slog.Warn("discord: interaction file failed, falling back to channel message", "error", err) + _, err = p.session.ChannelMessageSendComplex(rc.channelID, &discordgo.MessageSend{ + Files: []*discordgo.File{newFile()}, + }) + if err != nil { + return fmt.Errorf("discord: send file fallback: %w", err) + } + } + return nil + case replyContext: + _, err := p.session.ChannelMessageSendComplex(rc.targetChannelID(), &discordgo.MessageSend{ + Files: []*discordgo.File{newFile()}, + }) + if err != nil { + return fmt.Errorf("discord: send file: %w", err) + } + return nil + default: + return fmt.Errorf("discord: SendFile: invalid reply context type %T", rctx) + } +} + func buildDiscordActionRows(rows [][]core.ButtonOption) []discordgo.MessageComponent { components := make([]discordgo.MessageComponent, 0, len(rows)) for _, row := range rows { @@ -850,6 +906,7 @@ func (p *Platform) SendWithButtons(ctx context.Context, rctx any, content string } var _ core.ImageSender = (*Platform)(nil) +var _ core.FileSender = (*Platform)(nil) var _ core.InlineButtonSender = (*Platform)(nil) func (p *Platform) ReconstructReplyCtx(sessionKey string) (any, error) { diff --git a/platform/discord/discord_test.go b/platform/discord/discord_test.go index 9f6500e..2039004 100644 --- a/platform/discord/discord_test.go +++ b/platform/discord/discord_test.go @@ -391,6 +391,92 @@ func TestSendWithButtons_PreservesMultipleRows(t *testing.T) { } } +func TestSendFile_SendsChannelAttachment(t *testing.T) { + var contentType string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + contentType = r.Header.Get("Content-Type") + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"id":"msg-file","channel_id":"ch-1"}`) + })) + defer server.Close() + + oldEndpointDiscord := discordgo.EndpointDiscord + oldEndpointAPI := discordgo.EndpointAPI + oldEndpointChannels := discordgo.EndpointChannels + discordgo.EndpointDiscord = server.URL + "/" + discordgo.EndpointAPI = discordgo.EndpointDiscord + "api/v" + discordgo.APIVersion + "/" + discordgo.EndpointChannels = discordgo.EndpointAPI + "channels/" + defer func() { + discordgo.EndpointDiscord = oldEndpointDiscord + discordgo.EndpointAPI = oldEndpointAPI + discordgo.EndpointChannels = oldEndpointChannels + }() + + s, err := discordgo.New("Bot test-token") + if err != nil { + t.Fatalf("discordgo.New() error = %v", err) + } + s.Client = server.Client() + + p := &Platform{session: s} + err = p.SendFile(context.Background(), replyContext{channelID: "ch-1"}, core.FileAttachment{ + FileName: "report.pdf", + MimeType: "application/pdf", + Data: []byte("pdf-data"), + }) + if err != nil { + t.Fatalf("SendFile() error = %v", err) + } + if !strings.Contains(contentType, "multipart/form-data") { + t.Fatalf("content type = %q, want multipart/form-data", contentType) + } +} + +func TestSendFile_UsesInteractionEndpoints(t *testing.T) { + requests := make([]string, 0, 2) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"id":"msg-file","channel_id":"ch-1"}`) + })) + defer server.Close() + + oldEndpointDiscord := discordgo.EndpointDiscord + oldEndpointAPI := discordgo.EndpointAPI + oldEndpointChannels := discordgo.EndpointChannels + oldEndpointWebhooks := discordgo.EndpointWebhooks + discordgo.EndpointDiscord = server.URL + "/" + discordgo.EndpointAPI = discordgo.EndpointDiscord + "api/v" + discordgo.APIVersion + "/" + discordgo.EndpointChannels = discordgo.EndpointAPI + "channels/" + discordgo.EndpointWebhooks = discordgo.EndpointAPI + "webhooks/" + defer func() { + discordgo.EndpointDiscord = oldEndpointDiscord + discordgo.EndpointAPI = oldEndpointAPI + discordgo.EndpointChannels = oldEndpointChannels + discordgo.EndpointWebhooks = oldEndpointWebhooks + }() + + s, err := discordgo.New("Bot test-token") + if err != nil { + t.Fatalf("discordgo.New() error = %v", err) + } + s.Client = server.Client() + + p := &Platform{session: s} + rc := &interactionReplyCtx{interaction: &discordgo.Interaction{AppID: "app-1", Token: "token-1"}} + err = p.SendFile(context.Background(), rc, core.FileAttachment{ + FileName: "report.pdf", + MimeType: "application/pdf", + Data: []byte("pdf-data"), + }) + if err != nil { + t.Fatalf("SendFile() error = %v", err) + } + if len(requests) != 1 || !strings.Contains(requests[0], "/messages/@original") { + t.Fatalf("requests = %v, want one original interaction edit", requests) + } +} + // ── Dedup tests ────────────────────────────────────────────── // simulateHandlerCall mimics the dedup + dispatch logic in the MessageCreate