mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
639b32703a
* Support streaming * fix: stream pico reasoning updates Route Pico reasoning through the active streamer and hide empty thought placeholders. * fix: harden configured streaming delivery * fix ci * fix split issue
243 lines
6.6 KiB
Go
243 lines
6.6 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"reflect"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
)
|
|
|
|
type channelCatalogItem struct {
|
|
Name string `json:"name"`
|
|
ConfigKey string `json:"config_key"`
|
|
Variant string `json:"variant,omitempty"`
|
|
}
|
|
|
|
var channelCatalog = []channelCatalogItem{
|
|
{Name: "weixin", ConfigKey: "weixin"},
|
|
{Name: "telegram", ConfigKey: "telegram"},
|
|
{Name: "discord", ConfigKey: "discord"},
|
|
{Name: "slack", ConfigKey: "slack"},
|
|
{Name: "feishu", ConfigKey: "feishu"},
|
|
{Name: "dingtalk", ConfigKey: "dingtalk"},
|
|
{Name: "line", ConfigKey: "line"},
|
|
{Name: "qq", ConfigKey: "qq"},
|
|
{Name: "onebot", ConfigKey: "onebot"},
|
|
{Name: "wecom", ConfigKey: "wecom"},
|
|
{Name: "whatsapp", ConfigKey: "whatsapp", Variant: "bridge"},
|
|
{Name: "whatsapp_native", ConfigKey: "whatsapp", Variant: "native"},
|
|
{Name: "pico", ConfigKey: "pico"},
|
|
{Name: "maixcam", ConfigKey: "maixcam"},
|
|
{Name: "matrix", ConfigKey: "matrix"},
|
|
{Name: "irc", ConfigKey: "irc"},
|
|
{Name: "mqtt", ConfigKey: "mqtt"},
|
|
}
|
|
|
|
type channelConfigResponse struct {
|
|
Config any `json:"config"`
|
|
ConfiguredSecrets []string `json:"configured_secrets"`
|
|
ConfigKey string `json:"config_key"`
|
|
Variant string `json:"variant,omitempty"`
|
|
}
|
|
|
|
// registerChannelRoutes binds read-only channel catalog endpoints to the ServeMux.
|
|
func (h *Handler) registerChannelRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /api/channels/catalog", h.handleListChannelCatalog)
|
|
mux.HandleFunc("GET /api/channels/{name}/config", h.handleGetChannelConfig)
|
|
}
|
|
|
|
// handleListChannelCatalog returns the channels supported by backend.
|
|
//
|
|
// GET /api/channels/catalog
|
|
func (h *Handler) handleListChannelCatalog(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"channels": channelCatalog,
|
|
})
|
|
}
|
|
|
|
// handleGetChannelConfig returns safe channel config plus secret presence metadata.
|
|
//
|
|
// GET /api/channels/{name}/config
|
|
func (h *Handler) handleGetChannelConfig(w http.ResponseWriter, r *http.Request) {
|
|
channelName := r.PathValue("name")
|
|
item, ok := findChannelCatalogItem(channelName)
|
|
if !ok {
|
|
http.Error(w, "Channel not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
cfg, err := config.LoadConfig(h.configPath)
|
|
if err != nil {
|
|
http.Error(w, "Failed to load config", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
resp := buildChannelConfigResponse(cfg, item)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
func findChannelCatalogItem(name string) (channelCatalogItem, bool) {
|
|
for _, item := range channelCatalog {
|
|
if item.Name == name {
|
|
return item, true
|
|
}
|
|
}
|
|
return channelCatalogItem{}, false
|
|
}
|
|
|
|
var channelSecretFieldMap = map[string][]string{
|
|
"weixin": {"token"},
|
|
"telegram": {"token"},
|
|
"discord": {"token"},
|
|
"slack": {"bot_token", "app_token"},
|
|
"feishu": {"app_secret", "encrypt_key", "verification_token"},
|
|
"dingtalk": {"client_secret"},
|
|
"line": {"channel_secret", "channel_access_token"},
|
|
"qq": {"app_secret"},
|
|
"onebot": {"access_token"},
|
|
"wecom": {"secret"},
|
|
"pico": {"token"},
|
|
"matrix": {"access_token"},
|
|
"irc": {"password", "nickserv_password", "sasl_password"},
|
|
"whatsapp": {},
|
|
"whatsapp_native": {},
|
|
"maixcam": {},
|
|
"mqtt": {"username", "password"},
|
|
}
|
|
|
|
func buildChannelConfigResponse(cfg *config.Config, item channelCatalogItem) channelConfigResponse {
|
|
resp := channelConfigResponse{
|
|
ConfiguredSecrets: []string{},
|
|
ConfigKey: item.ConfigKey,
|
|
Variant: item.Variant,
|
|
}
|
|
|
|
bc := cfg.Channels.Get(item.ConfigKey)
|
|
if bc == nil {
|
|
bc = defaultChannelConfig(item.ConfigKey)
|
|
if bc == nil {
|
|
resp.Config = map[string]any{}
|
|
return resp
|
|
}
|
|
}
|
|
|
|
// Detect configured secrets by checking the raw Settings JSON
|
|
secrets := detectConfiguredSecrets(bc.Settings, item.Name)
|
|
resp.ConfiguredSecrets = secrets
|
|
|
|
// Parse settings into a generic map for JSON response
|
|
settings := map[string]any{}
|
|
if len(bc.Settings) > 0 {
|
|
if err := json.Unmarshal(bc.Settings, &settings); err != nil {
|
|
resp.Config = map[string]any{}
|
|
return resp
|
|
}
|
|
}
|
|
|
|
// Remove secure fields from response
|
|
for _, key := range secrets {
|
|
delete(settings, key)
|
|
}
|
|
addChannelCommonConfig(settings, bc)
|
|
resp.Config = settings
|
|
|
|
return resp
|
|
}
|
|
|
|
func defaultChannelConfig(configKey string) *config.Channel {
|
|
return config.DefaultConfig().Channels.Get(configKey)
|
|
}
|
|
|
|
func addChannelCommonConfig(settings map[string]any, bc *config.Channel) {
|
|
settings["enabled"] = bc.Enabled
|
|
if len(bc.AllowFrom) > 0 {
|
|
settings["allow_from"] = []string(bc.AllowFrom)
|
|
}
|
|
if bc.ReasoningChannelID != "" {
|
|
settings["reasoning_channel_id"] = bc.ReasoningChannelID
|
|
}
|
|
if bc.GroupTrigger.MentionOnly || len(bc.GroupTrigger.Prefixes) > 0 {
|
|
settings["group_trigger"] = bc.GroupTrigger
|
|
}
|
|
if bc.Typing.Enabled {
|
|
settings["typing"] = bc.Typing
|
|
}
|
|
if bc.Placeholder.Enabled || len(bc.Placeholder.Text) > 0 {
|
|
settings["placeholder"] = bc.Placeholder
|
|
}
|
|
if _, exists := settings["streaming"]; !exists {
|
|
if streaming, ok := channelStreamingConfig(bc); ok {
|
|
if !streaming.IsZero() {
|
|
settings["streaming"] = streaming
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func channelStreamingConfig(bc *config.Channel) (config.StreamingConfig, bool) {
|
|
if bc == nil {
|
|
return config.StreamingConfig{}, false
|
|
}
|
|
|
|
decoded, err := bc.GetDecoded()
|
|
if err != nil || decoded == nil {
|
|
return config.StreamingConfig{}, false
|
|
}
|
|
|
|
value := reflect.ValueOf(decoded)
|
|
if value.Kind() == reflect.Pointer {
|
|
if value.IsNil() {
|
|
return config.StreamingConfig{}, false
|
|
}
|
|
value = value.Elem()
|
|
}
|
|
if value.Kind() != reflect.Struct {
|
|
return config.StreamingConfig{}, false
|
|
}
|
|
|
|
field := value.FieldByName("Streaming")
|
|
if !field.IsValid() || !field.CanInterface() {
|
|
return config.StreamingConfig{}, false
|
|
}
|
|
streaming, ok := field.Interface().(config.StreamingConfig)
|
|
return streaming, ok
|
|
}
|
|
|
|
func detectConfiguredSecrets(settings config.RawNode, channelName string) []string {
|
|
var m map[string]any
|
|
if err := json.Unmarshal(settings, &m); err != nil {
|
|
return nil
|
|
}
|
|
|
|
fields, ok := channelSecretFieldMap[channelName]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
var found []string
|
|
for _, key := range fields {
|
|
if val, exists := m[key]; exists {
|
|
switch v := val.(type) {
|
|
case string:
|
|
if v != "" {
|
|
found = append(found, key)
|
|
}
|
|
case map[string]any:
|
|
if s, ok := v["s"].(string); ok && s != "" {
|
|
found = append(found, key)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if found == nil {
|
|
return []string{}
|
|
}
|
|
return found
|
|
}
|