Merge github/main into main (sync Discord file send + cron job details)
This commit is contained in:
commit
e64aa04813
4 files changed
+265
No files matched your search
@@ -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
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user