Files
MengMengCode f3e4b675fb feat: add Telegram API URL handling and version comparison utilities
- Implemented `telegramAPIURL` and `validateTelegramAPIURL` functions for constructing and validating Telegram API URLs.
- Added semantic versioning utilities in `version.go` to compare versions and validate semantic version formats.
- Created tests for version comparison logic in `version_test.go`.
- Introduced IMS call handling in `call_runtime.go`, including methods for dialing, answering, and hanging up calls.
- Added tests for incoming call handling and validation in `call_runtime_test.go`.
- Developed a new `PluginsCard` component for managing plugins via URL or file upload in the web interface.
- Implemented plugin management functions in `extensions.ts` and created an `ExtensionPage` for displaying plugin contributions.
2026-08-09 19:28:12 +08:00

136 lines
4.4 KiB
Go

package extensions
import (
"encoding/json"
"errors"
"fmt"
"io"
"regexp"
"runtime"
"sort"
"strings"
)
const (
ManifestFilename = "vocat-plugin.json"
SchemaVersion = 1
)
var pluginIDPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{1,62}[a-z0-9]$`)
type Contribution struct {
ID string `json:"id"`
Label string `json:"label"`
LabelZH string `json:"label_zh,omitempty"`
Location string `json:"location"`
After string `json:"after,omitempty"`
Entry string `json:"entry"`
}
type Backend struct {
Commands map[string]string `json:"commands,omitempty"`
}
type Manifest struct {
SchemaVersion int `json:"schema_version"`
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description,omitempty"`
Author string `json:"author,omitempty"`
Homepage string `json:"homepage,omitempty"`
Permissions []string `json:"permissions,omitempty"`
Contributions []Contribution `json:"contributions"`
Backend *Backend `json:"backend,omitempty"`
}
func DecodeManifest(reader io.Reader) (Manifest, error) {
decoder := json.NewDecoder(io.LimitReader(reader, 256<<10))
decoder.DisallowUnknownFields()
var manifest Manifest
if err := decoder.Decode(&manifest); err != nil {
return Manifest{}, fmt.Errorf("decode %s: %w", ManifestFilename, err)
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return Manifest{}, errors.New("plugin manifest must contain one JSON object")
}
if err := manifest.Validate(); err != nil {
return Manifest{}, err
}
return manifest, nil
}
func (manifest Manifest) Validate() error {
if manifest.SchemaVersion != SchemaVersion {
return fmt.Errorf("unsupported plugin schema_version %d", manifest.SchemaVersion)
}
if !pluginIDPattern.MatchString(manifest.ID) {
return errors.New("plugin id must be 3-64 lowercase letters, digits, or hyphens")
}
if strings.TrimSpace(manifest.Name) == "" || len(manifest.Name) > 100 {
return errors.New("plugin name is required and must not exceed 100 characters")
}
if strings.TrimSpace(manifest.Version) == "" || len(manifest.Version) > 64 {
return errors.New("plugin version is required and must not exceed 64 characters")
}
seen := make(map[string]struct{}, len(manifest.Contributions))
for _, contribution := range manifest.Contributions {
if !pluginIDPattern.MatchString(contribution.ID) {
return fmt.Errorf("invalid contribution id %q", contribution.ID)
}
if _, duplicate := seen[contribution.ID]; duplicate {
return fmt.Errorf("duplicate contribution id %q", contribution.ID)
}
seen[contribution.ID] = struct{}{}
if contribution.Location != "sidebar" && contribution.Location != "proxy" {
return fmt.Errorf("contribution %q has unsupported location %q", contribution.ID, contribution.Location)
}
if strings.TrimSpace(contribution.Label) == "" {
return fmt.Errorf("contribution %q requires a label", contribution.ID)
}
if !safeRelativePath(contribution.Entry) {
return fmt.Errorf("contribution %q has an unsafe entry path", contribution.ID)
}
}
if manifest.Backend != nil {
if len(manifest.Backend.Commands) == 0 {
return errors.New("plugin backend commands are empty")
}
for platform, command := range manifest.Backend.Commands {
if !strings.Contains(platform, "/") || !safeRelativePath(command) {
return fmt.Errorf("plugin backend command for %q is invalid", platform)
}
}
}
permissions := append([]string(nil), manifest.Permissions...)
sort.Strings(permissions)
for index, permission := range permissions {
if strings.TrimSpace(permission) == "" || (index > 0 && permission == permissions[index-1]) {
return errors.New("plugin permissions must be non-empty and unique")
}
}
return nil
}
func (manifest Manifest) BackendCommand() (string, bool) {
if manifest.Backend == nil {
return "", false
}
command, ok := manifest.Backend.Commands[runtime.GOOS+"/"+runtime.GOARCH]
return command, ok
}
func safeRelativePath(value string) bool {
value = strings.ReplaceAll(strings.TrimSpace(value), `\`, "/")
if value == "" || strings.HasPrefix(value, "/") || strings.Contains(value, ":") {
return false
}
for _, segment := range strings.Split(value, "/") {
if segment == "" || segment == "." || segment == ".." {
return false
}
}
return true
}