Merge github/main into main (sync Discord file send + cron job details)

This commit is contained in:
Claude committed 2026-03-24 23:15:20 +08:00
commit e64aa04813
4 files changed
+265

No files matched your search

+96
View File
@@ -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 <id> [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 <id> [field] Show detailed info of a scheduled task
(optionally filter to a single field)
del <id> Delete a scheduled task
Run 'cc-connect cron <command> --help' for details.`)
+26
View File
@@ -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)
+57
View File
@@ -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) {
+86
View File
@@ -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