feat(composer): add command preview and autocomplete
This commit is contained in:
parent
eb4028957d
commit
778c3c4423
9 changed files with 699 additions and 11 deletions
244
internal/command/command.go
Normal file
244
internal/command/command.go
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
package command
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ayn2op/arikawa/v3/api"
|
||||||
|
"github.com/ayn2op/arikawa/v3/discord"
|
||||||
|
"github.com/sahilm/fuzzy"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Item struct {
|
||||||
|
Name string
|
||||||
|
Usage string
|
||||||
|
Description string
|
||||||
|
Source string
|
||||||
|
Builtin bool
|
||||||
|
Expand func(args string) string
|
||||||
|
RequiredPerm discord.Permissions
|
||||||
|
}
|
||||||
|
|
||||||
|
type itemList []Item
|
||||||
|
|
||||||
|
func (l itemList) String(i int) string {
|
||||||
|
return l[i].Name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l itemList) Len() int {
|
||||||
|
return len(l)
|
||||||
|
}
|
||||||
|
|
||||||
|
var Builtins = []Item{
|
||||||
|
{
|
||||||
|
Name: "shrug",
|
||||||
|
Usage: "[message]",
|
||||||
|
Description: "Appends ¯\\_(ツ)_/¯ to your message",
|
||||||
|
Source: "Built-in",
|
||||||
|
Builtin: true,
|
||||||
|
Expand: func(args string) string {
|
||||||
|
if args == "" {
|
||||||
|
return `¯\_(ツ)_/¯`
|
||||||
|
}
|
||||||
|
return args + ` ¯\_(ツ)_/¯`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "tableflip",
|
||||||
|
Usage: "[message]",
|
||||||
|
Description: "Appends (╯°□°)╯︵ ┻━┻ to your message",
|
||||||
|
Source: "Built-in",
|
||||||
|
Builtin: true,
|
||||||
|
Expand: func(args string) string {
|
||||||
|
if args == "" {
|
||||||
|
return `(╯°□°)╯︵ ┻━┻`
|
||||||
|
}
|
||||||
|
return args + ` (╯°□°)╯︵ ┻━┻`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "unflip",
|
||||||
|
Usage: "[message]",
|
||||||
|
Description: "Appends ┬─┬ノ( º _ ºノ) to your message",
|
||||||
|
Source: "Built-in",
|
||||||
|
Builtin: true,
|
||||||
|
Expand: func(args string) string {
|
||||||
|
if args == "" {
|
||||||
|
return `┬─┬ノ( º _ ºノ)`
|
||||||
|
}
|
||||||
|
return args + ` ┬─┬ノ( º _ ºノ)`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "me",
|
||||||
|
Usage: "<message>",
|
||||||
|
Description: "Displays text with emphasis",
|
||||||
|
Source: "Built-in",
|
||||||
|
Builtin: true,
|
||||||
|
Expand: func(args string) string {
|
||||||
|
if args == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "*" + args + "*"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "spoiler",
|
||||||
|
Usage: "<message>",
|
||||||
|
Description: "Marks your message as a spoiler",
|
||||||
|
Source: "Built-in",
|
||||||
|
Builtin: true,
|
||||||
|
Expand: func(args string) string {
|
||||||
|
if args == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "||" + args + "||"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "nick",
|
||||||
|
Usage: "[new_nickname]",
|
||||||
|
Description: "Changes your nickname on the current server",
|
||||||
|
Source: "Built-in",
|
||||||
|
Builtin: true,
|
||||||
|
RequiredPerm: discord.PermissionChangeNickname,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpandBuiltin expands recognized built-in slash commands if matched.
|
||||||
|
func ExpandBuiltin(text string) (string, bool) {
|
||||||
|
if !strings.HasPrefix(text, "/") {
|
||||||
|
return text, false
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(text[1:], " ", 2)
|
||||||
|
name := parts[0]
|
||||||
|
var args string
|
||||||
|
if len(parts) > 1 {
|
||||||
|
args = parts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cmd := range Builtins {
|
||||||
|
if strings.EqualFold(cmd.Name, name) && cmd.Expand != nil {
|
||||||
|
return cmd.Expand(args), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search filters built-in and bot application commands based on query and permissions.
|
||||||
|
func Search(query string, channelID discord.ChannelID, userPerms discord.Permissions, botCmds []Item, limit int) []Item {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidates []Item
|
||||||
|
isAdmin := userPerms.Has(discord.PermissionAdministrator)
|
||||||
|
|
||||||
|
for _, b := range Builtins {
|
||||||
|
if b.RequiredPerm != 0 && !isAdmin && !userPerms.Has(b.RequiredPerm) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
candidates = append(candidates, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, b := range botCmds {
|
||||||
|
if b.RequiredPerm != 0 && !isAdmin && !userPerms.Has(b.RequiredPerm) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
candidates = append(candidates, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
if query == "" {
|
||||||
|
if len(candidates) > limit {
|
||||||
|
return candidates[:limit]
|
||||||
|
}
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
lowerQuery := strings.ToLower(query)
|
||||||
|
var results []Item
|
||||||
|
for _, c := range candidates {
|
||||||
|
if strings.HasPrefix(strings.ToLower(c.Name), lowerQuery) {
|
||||||
|
results = append(results, c)
|
||||||
|
if len(results) >= limit {
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := fuzzy.FindFrom(query, itemList(candidates))
|
||||||
|
for _, m := range matches {
|
||||||
|
item := candidates[m.Index]
|
||||||
|
if !slices.ContainsFunc(results, func(it Item) bool { return it.Name == item.Name && it.Source == item.Source }) {
|
||||||
|
results = append(results, item)
|
||||||
|
if len(results) >= limit {
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
type searchResponse struct {
|
||||||
|
Applications []struct {
|
||||||
|
ID discord.AppID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"applications"`
|
||||||
|
ApplicationCommands []discord.Command `json:"application_commands"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchBotCommands queries Discord's channel application commands endpoint for slash commands.
|
||||||
|
func FetchBotCommands(ctx context.Context, client *api.Client, channelID discord.ChannelID, query string) ([]Item, error) {
|
||||||
|
if client == nil || !channelID.IsValid() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("https://discord.com/api/v9/channels/%s/application-commands/search?query=%s&limit=25&include_applications=true",
|
||||||
|
channelID.String(), url.QueryEscape(query))
|
||||||
|
|
||||||
|
var resp searchResponse
|
||||||
|
if err := client.WithContext(ctx).RequestJSONInto(&resp, "GET", endpoint); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
appNames := make(map[discord.AppID]string, len(resp.Applications))
|
||||||
|
for _, app := range resp.Applications {
|
||||||
|
appNames[app.ID] = app.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]Item, 0, len(resp.ApplicationCommands))
|
||||||
|
for _, cmd := range resp.ApplicationCommands {
|
||||||
|
if cmd.Type != 0 && cmd.Type != discord.ChatInputCommand {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
source := appNames[cmd.AppID]
|
||||||
|
if source == "" {
|
||||||
|
source = "Bot"
|
||||||
|
}
|
||||||
|
|
||||||
|
var reqPerm discord.Permissions
|
||||||
|
if cmd.DefaultMemberPermissions != nil {
|
||||||
|
reqPerm = discord.Permissions(*cmd.DefaultMemberPermissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
var usageParts []string
|
||||||
|
for _, opt := range cmd.Options {
|
||||||
|
usageParts = append(usageParts, "["+opt.Name()+"]")
|
||||||
|
}
|
||||||
|
|
||||||
|
items = append(items, Item{
|
||||||
|
Name: cmd.Name,
|
||||||
|
Usage: strings.Join(usageParts, " "),
|
||||||
|
Description: cmd.Description,
|
||||||
|
Source: source,
|
||||||
|
Builtin: false,
|
||||||
|
RequiredPerm: reqPerm,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
83
internal/command/command_test.go
Normal file
83
internal/command/command_test.go
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
package command
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ayn2op/arikawa/v3/discord"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuiltinsSearch(t *testing.T) {
|
||||||
|
// Query "shrug"
|
||||||
|
results := Search("shrug", 0, discord.PermissionSendMessages, nil, 10)
|
||||||
|
if len(results) == 0 {
|
||||||
|
t.Fatal("expected results for 'shrug', got none")
|
||||||
|
}
|
||||||
|
if results[0].Name != "shrug" {
|
||||||
|
t.Errorf("expected top result 'shrug', got %q", results[0].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query "flip"
|
||||||
|
flipResults := Search("flip", 0, discord.PermissionSendMessages, nil, 10)
|
||||||
|
if len(flipResults) < 2 {
|
||||||
|
t.Fatalf("expected at least 2 flip results, got %d", len(flipResults))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPermissionFiltering(t *testing.T) {
|
||||||
|
perm := discord.Permissions(discord.PermissionManageMessages)
|
||||||
|
botCmds := []Item{
|
||||||
|
{
|
||||||
|
Name: "purge",
|
||||||
|
Description: "Delete messages",
|
||||||
|
Source: "ModBot",
|
||||||
|
RequiredPerm: perm,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without manage messages permission
|
||||||
|
noPerm := Search("purge", 0, discord.PermissionSendMessages, botCmds, 10)
|
||||||
|
if len(noPerm) != 0 {
|
||||||
|
t.Errorf("expected purge to be filtered out without permission, got %v", noPerm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// With manage messages permission
|
||||||
|
withPerm := Search("purge", 0, discord.PermissionSendMessages|discord.PermissionManageMessages, botCmds, 10)
|
||||||
|
if len(withPerm) != 1 {
|
||||||
|
t.Fatalf("expected purge to be visible with permission, got %v", withPerm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// With administrator permission
|
||||||
|
admin := Search("purge", 0, discord.PermissionAdministrator, botCmds, 10)
|
||||||
|
if len(admin) != 1 {
|
||||||
|
t.Fatalf("expected purge to be visible with admin permission, got %v", admin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpandBuiltin(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
expanded bool
|
||||||
|
}{
|
||||||
|
{"/shrug", `¯\_(ツ)_/¯`, true},
|
||||||
|
{"/shrug hello", `hello ¯\_(ツ)_/¯`, true},
|
||||||
|
{"/tableflip", `(╯°□°)╯︵ ┻━┻`, true},
|
||||||
|
{"/tableflip angry", `angry (╯°□°)╯︵ ┻━┻`, true},
|
||||||
|
{"/unflip", `┬─┬ノ( º _ ºノ)`, true},
|
||||||
|
{"/me dancing", `*dancing*`, true},
|
||||||
|
{"/spoiler secret text", `||secret text||`, true},
|
||||||
|
{"normal message", `normal message`, false},
|
||||||
|
{"/unknown command", `/unknown command`, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got, ok := ExpandBuiltin(tt.input)
|
||||||
|
if ok != tt.expanded {
|
||||||
|
t.Errorf("ExpandBuiltin(%q) ok = %v, want %v", tt.input, ok, tt.expanded)
|
||||||
|
}
|
||||||
|
if ok && strings.TrimSpace(got) != strings.TrimSpace(tt.want) {
|
||||||
|
t.Errorf("ExpandBuiltin(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
internal/ui/chat/commandslist/item.go
Normal file
13
internal/ui/chat/commandslist/item.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package commandslist
|
||||||
|
|
||||||
|
import "github.com/ayn2op/tview"
|
||||||
|
|
||||||
|
type Item struct {
|
||||||
|
Name string
|
||||||
|
Usage string
|
||||||
|
Description string
|
||||||
|
Source string
|
||||||
|
InsertText string
|
||||||
|
DisplayText string
|
||||||
|
Style tview.Style
|
||||||
|
}
|
||||||
103
internal/ui/chat/commandslist/model.go
Normal file
103
internal/ui/chat/commandslist/model.go
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
package commandslist
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/ayn2op/discordo/internal/config"
|
||||||
|
"github.com/ayn2op/discordo/internal/ui"
|
||||||
|
"github.com/ayn2op/tview"
|
||||||
|
"github.com/ayn2op/tview/layout"
|
||||||
|
"github.com/ayn2op/tview/list"
|
||||||
|
"github.com/ayn2op/tview/scrollbar"
|
||||||
|
"github.com/ayn2op/tview/text"
|
||||||
|
"github.com/ayn2op/tview/viewport"
|
||||||
|
"github.com/rockorager/go-uucode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Model shows command suggestions as a list in a box.
|
||||||
|
type Model struct {
|
||||||
|
cfg *config.Config
|
||||||
|
|
||||||
|
items []Item
|
||||||
|
cursor int
|
||||||
|
scrollState viewport.ScrollState
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewModel(cfg *config.Config) Model {
|
||||||
|
return Model{cfg: cfg, cursor: -1}
|
||||||
|
}
|
||||||
|
|
||||||
|
// View shows the list in a box whose bottom corners join the composer's border below it.
|
||||||
|
func (m Model) View() tview.Widget {
|
||||||
|
set := m.cfg.UI.Border.NormalSet.BorderSet
|
||||||
|
set.BottomLeft, set.BottomRight = set.BottomT, set.BottomT
|
||||||
|
cfg := m.cfg.UI.MentionsList
|
||||||
|
list, width := m.listView().Height(layout.Shrink.Max(int(cfg.MaxHeight))), layout.Fill
|
||||||
|
if cfg.MinWidth != 0 {
|
||||||
|
list, width = list.Width(layout.Fixed(max(int(cfg.MinWidth), m.maxDisplayWidth()))), layout.Shrink
|
||||||
|
}
|
||||||
|
|
||||||
|
return ui.Box(list, m.cfg, false).Title(ui.Label("Commands", m.cfg.Theme.Title, false)).BorderSet(set).Width(width).Height(layout.Shrink)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Model) Init() tview.Cmd { return nil }
|
||||||
|
|
||||||
|
func (m Model) listView() viewport.Widget {
|
||||||
|
l := list.New(m.cursor, len(m.items), func(i int) tview.Widget {
|
||||||
|
item := m.items[i]
|
||||||
|
return text.New(item.DisplayText).Style(item.Style).Wrapping(text.WrappingNone)
|
||||||
|
}).
|
||||||
|
SelectedStyle(m.cfg.Theme.MentionsList.SelectedStyle.Style).
|
||||||
|
Keybind(ui.SelectionKeybind(m.cfg.Keybinds.MentionsList.SelectionKeybinds)).
|
||||||
|
Focused(true).
|
||||||
|
OnChange(func(cursor int) tview.Msg { return listMsg(cursor) })
|
||||||
|
return viewport.New(l, m.scrollState).
|
||||||
|
Target(l.Target(m.cursor)).
|
||||||
|
ScrollBar(scrollbar.New(), viewport.ScrollBarVisibilityAutomatic).
|
||||||
|
OnChange(func(s *viewport.ScrollState) tview.Msg { return scrollMsg(*s) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case listMsg:
|
||||||
|
m.cursor = int(msg)
|
||||||
|
m.scrollState.ScrollToTarget()
|
||||||
|
case scrollMsg:
|
||||||
|
m.scrollState = viewport.ScrollState(msg)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) Append(item Item) {
|
||||||
|
m.items = append(m.items, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) Clear() {
|
||||||
|
m.items = nil
|
||||||
|
m.cursor = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) ItemCount() int {
|
||||||
|
return len(m.items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) SelectedInsertText() (string, bool) {
|
||||||
|
index := m.cursor
|
||||||
|
if index < 0 || index >= len(m.items) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return m.items[index].InsertText, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) maxDisplayWidth() int {
|
||||||
|
width := 0
|
||||||
|
for _, item := range m.items {
|
||||||
|
width = max(width, uucode.StringWidth(item.DisplayText))
|
||||||
|
}
|
||||||
|
return width
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) Rebuild() {
|
||||||
|
m.cursor = min(0, len(m.items)-1)
|
||||||
|
m.scrollState.ScrollToTarget()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ tview.Model[Model] = Model{}
|
||||||
9
internal/ui/chat/commandslist/msg.go
Normal file
9
internal/ui/chat/commandslist/msg.go
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
package commandslist
|
||||||
|
|
||||||
|
import "github.com/ayn2op/tview/viewport"
|
||||||
|
|
||||||
|
type Msg any
|
||||||
|
|
||||||
|
type listMsg int
|
||||||
|
|
||||||
|
type scrollMsg viewport.ScrollState
|
||||||
|
|
@ -22,9 +22,11 @@ import (
|
||||||
"github.com/ayn2op/arikawa/v3/state"
|
"github.com/ayn2op/arikawa/v3/state"
|
||||||
"github.com/ayn2op/arikawa/v3/utils/json/option"
|
"github.com/ayn2op/arikawa/v3/utils/json/option"
|
||||||
"github.com/ayn2op/arikawa/v3/utils/sendpart"
|
"github.com/ayn2op/arikawa/v3/utils/sendpart"
|
||||||
|
"github.com/ayn2op/discordo/internal/command"
|
||||||
"github.com/ayn2op/discordo/internal/config"
|
"github.com/ayn2op/discordo/internal/config"
|
||||||
"github.com/ayn2op/discordo/internal/consts"
|
"github.com/ayn2op/discordo/internal/consts"
|
||||||
"github.com/ayn2op/discordo/internal/ui"
|
"github.com/ayn2op/discordo/internal/ui"
|
||||||
|
"github.com/ayn2op/discordo/internal/ui/chat/commandslist"
|
||||||
"github.com/ayn2op/discordo/internal/ui/chat/mentionslist"
|
"github.com/ayn2op/discordo/internal/ui/chat/mentionslist"
|
||||||
"github.com/ayn2op/ningen/v3"
|
"github.com/ayn2op/ningen/v3"
|
||||||
"github.com/ayn2op/tview"
|
"github.com/ayn2op/tview"
|
||||||
|
|
@ -71,6 +73,8 @@ type Model struct {
|
||||||
sendMessageData *api.SendMessageData
|
sendMessageData *api.SendMessageData
|
||||||
memberSearchCache map[string]uint
|
memberSearchCache map[string]uint
|
||||||
mentionsList mentionslist.Model
|
mentionsList mentionslist.Model
|
||||||
|
commandsList commandslist.Model
|
||||||
|
botCommands []command.Item
|
||||||
lastSearch time.Time
|
lastSearch time.Time
|
||||||
|
|
||||||
typingUntil time.Time
|
typingUntil time.Time
|
||||||
|
|
@ -87,13 +91,14 @@ func NewModel(cfg *config.Config, state *ningen.State) Model {
|
||||||
sendMessageData: &api.SendMessageData{},
|
sendMessageData: &api.SendMessageData{},
|
||||||
memberSearchCache: make(map[string]uint),
|
memberSearchCache: make(map[string]uint),
|
||||||
mentionsList: mentionslist.NewModel(cfg),
|
mentionsList: mentionslist.NewModel(cfg),
|
||||||
|
commandsList: commandslist.NewModel(cfg),
|
||||||
}
|
}
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetChannel sets the channel messages are sent to, disabling the composer if the user may not send messages there.
|
// SetChannel sets the channel messages are sent to, disabling the composer if the user may not send messages there.
|
||||||
func (m *Model) SetChannel(channel *discord.Channel) {
|
func (m *Model) SetChannel(channel *discord.Channel) {
|
||||||
m.channel, m.typingUntil = channel, time.Time{}
|
m.channel, m.typingUntil, m.botCommands = channel, time.Time{}, nil
|
||||||
isDM := channel.Type == discord.DirectMessage || channel.Type == discord.GroupDM
|
isDM := channel.Type == discord.DirectMessage || channel.Type == discord.GroupDM
|
||||||
|
|
||||||
// Sending in a thread takes another permission than in the channel it is in.
|
// Sending in a thread takes another permission than in the channel it is in.
|
||||||
|
|
@ -266,7 +271,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||||
m.pasteText()
|
m.pasteText()
|
||||||
return m, pasteImage()
|
return m, pasteImage()
|
||||||
case keybind.Matches(msg, kbs.Send.Keybind):
|
case keybind.Matches(msg, kbs.Send.Keybind):
|
||||||
if m.mentionsListVisible() {
|
if m.mentionsListVisible() || m.commandsListVisible() {
|
||||||
return m, m.tabComplete()
|
return m, m.tabComplete()
|
||||||
}
|
}
|
||||||
return m, m.send()
|
return m, m.send()
|
||||||
|
|
@ -277,14 +282,14 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||||
m.stopTabCompletion()
|
m.stopTabCompletion()
|
||||||
return m, m.pickFiles()
|
return m, m.pickFiles()
|
||||||
case keybind.Matches(msg, kbs.Cancel.Keybind):
|
case keybind.Matches(msg, kbs.Cancel.Keybind):
|
||||||
if m.mentionsListVisible() {
|
if m.mentionsListVisible() || m.commandsListVisible() {
|
||||||
m.stopTabCompletion()
|
m.stopTabCompletion()
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.reset()
|
m.reset()
|
||||||
return m, nil
|
return m, nil
|
||||||
case keybind.Matches(msg, kbs.TabComplete.Keybind):
|
case keybind.Matches(msg, kbs.TabComplete.Keybind):
|
||||||
if m.mentionsListVisible() {
|
if m.mentionsListVisible() || m.commandsListVisible() {
|
||||||
return m, m.tabComplete()
|
return m, m.tabComplete()
|
||||||
}
|
}
|
||||||
m.insert("\t")
|
m.insert("\t")
|
||||||
|
|
@ -300,6 +305,17 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||||
var cmd tview.Cmd
|
var cmd tview.Cmd
|
||||||
m.mentionsList, cmd = m.mentionsList.Update(msg)
|
m.mentionsList, cmd = m.mentionsList.Update(msg)
|
||||||
return m, cmd
|
return m, cmd
|
||||||
|
case commandslist.Msg:
|
||||||
|
var cmd tview.Cmd
|
||||||
|
m.commandsList, cmd = m.commandsList.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
case botCommandsMsg:
|
||||||
|
m.botCommands = msg.commands
|
||||||
|
if m.commandsListVisible() || (strings.HasPrefix(m.text, "/") && !strings.Contains(m.text, " ")) {
|
||||||
|
query := strings.TrimPrefix(m.text[:m.cursor], "/")
|
||||||
|
return m, m.suggestCommands(query)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
@ -307,7 +323,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||||
func (m Model) canEditLastMessage() bool {
|
func (m Model) canEditLastMessage() bool {
|
||||||
return !m.Disabled() && m.editing == nil && m.text == "" &&
|
return !m.Disabled() && m.editing == nil && m.text == "" &&
|
||||||
m.sendMessageData.Reference == nil && len(m.sendMessageData.Files) == 0 &&
|
m.sendMessageData.Reference == nil && len(m.sendMessageData.Files) == 0 &&
|
||||||
!m.mentionsListVisible()
|
!m.mentionsListVisible() && !m.commandsListVisible()
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartEdit puts message in the composer to be edited.
|
// StartEdit puts message in the composer to be edited.
|
||||||
|
|
@ -500,12 +516,23 @@ func (m Model) OnGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MentionsListView places the mentions list at the bottom of the area above the composer, near the cursor, or returns nil if it is empty or the composer is not focused.
|
// AutocompleteView places the active completion list (mentions or commands) at the bottom of the area above the composer, near the cursor, or returns nil if none are active or the composer is not focused.
|
||||||
func (m Model) MentionsListView(focused bool) tview.Widget {
|
func (m Model) AutocompleteView(focused bool) tview.Widget {
|
||||||
if !focused || !m.mentionsListVisible() {
|
if !focused {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return mentionsPopup{m}
|
if m.commandsListVisible() {
|
||||||
|
return commandsPopup{m}
|
||||||
|
}
|
||||||
|
if m.mentionsListVisible() {
|
||||||
|
return mentionsPopup{m}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MentionsListView is an alias for AutocompleteView.
|
||||||
|
func (m Model) MentionsListView(focused bool) tview.Widget {
|
||||||
|
return m.AutocompleteView(focused)
|
||||||
}
|
}
|
||||||
|
|
||||||
// mentionsPopup places the mentions list within the area of the messages, which it is drawn over.
|
// mentionsPopup places the mentions list within the area of the messages, which it is drawn over.
|
||||||
|
|
@ -539,6 +566,31 @@ func (p mentionsPopup) area(area tview.Rectangle) tview.Rectangle {
|
||||||
return tview.Rectangle{X: area.X + x, Y: area.Y + area.Height - size.Height, Width: size.Width, Height: size.Height}
|
return tview.Rectangle{X: area.X + x, Y: area.Y + area.Height - size.Height, Width: size.Width, Height: size.Height}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type commandsPopup struct {
|
||||||
|
c Model
|
||||||
|
}
|
||||||
|
|
||||||
|
func (commandsPopup) Size() (width, height layout.Length) { return layout.Fill, layout.Fill }
|
||||||
|
|
||||||
|
func (commandsPopup) Layout(limits layout.Limits) layout.Size {
|
||||||
|
return layout.Atomic(limits, layout.Fill, layout.Fill)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p commandsPopup) Draw(screen tview.Screen, area tview.Rectangle) {
|
||||||
|
p.c.commandsList.View().Draw(screen, p.area(area))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p commandsPopup) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg {
|
||||||
|
return p.c.commandsList.View().Handle(msg, p.area(area))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p commandsPopup) area(area tview.Rectangle) tview.Rectangle {
|
||||||
|
c := p.c
|
||||||
|
size := c.commandsList.View().Layout(layout.Limits{Max: layout.Size{Width: area.Width, Height: area.Height}})
|
||||||
|
x := c.box(false).InnerArea(tview.Rectangle{}).X
|
||||||
|
return tview.Rectangle{X: area.X + x, Y: area.Y + area.Height - size.Height, Width: size.Width, Height: size.Height}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) addMentionMember(gID discord.GuildID, member *discord.Member) bool {
|
func (m *Model) addMentionMember(gID discord.GuildID, member *discord.Member) bool {
|
||||||
if member == nil {
|
if member == nil {
|
||||||
return false
|
return false
|
||||||
|
|
@ -593,10 +645,11 @@ func (m *Model) addMentionUser(user *discord.User) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// stopTabCompletion clears the mentions list, which hides it, when it is in use.
|
// stopTabCompletion clears the autocomplete lists, which hides them, when they are in use.
|
||||||
func (m *Model) stopTabCompletion() {
|
func (m *Model) stopTabCompletion() {
|
||||||
if m.cfg.AutocompleteLimit > 0 {
|
if m.cfg.AutocompleteLimit > 0 {
|
||||||
m.mentionsList.Clear()
|
m.mentionsList.Clear()
|
||||||
|
m.commandsList.Clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -605,6 +658,10 @@ func (m Model) mentionsListVisible() bool {
|
||||||
return m.mentionsList.ItemCount() != 0
|
return m.mentionsList.ItemCount() != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m Model) commandsListVisible() bool {
|
||||||
|
return m.commandsList.ItemCount() != 0
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) attach(name string, reader io.Reader) {
|
func (m Model) attach(name string, reader io.Reader) {
|
||||||
m.sendMessageData.Files = append(m.sendMessageData.Files, sendpart.File{Name: name, Reader: reader})
|
m.sendMessageData.Files = append(m.sendMessageData.Files, sendpart.File{Name: name, Reader: reader})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -200,3 +200,44 @@ func TestModelProcessText(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestModelCommandSuggest(t *testing.T) {
|
||||||
|
c, _ := newTestModel(t)
|
||||||
|
channel := discord.Channel{ID: 1, Type: discord.DirectMessage}
|
||||||
|
c.SetChannel(&channel)
|
||||||
|
c.setText("/", 1)
|
||||||
|
c.tabSuggest()
|
||||||
|
if got := c.commandsList.ItemCount(); got == 0 {
|
||||||
|
t.Fatal("expected command suggestions for '/', got none")
|
||||||
|
}
|
||||||
|
if !c.commandsListVisible() {
|
||||||
|
t.Fatal("expected commandsListVisible to be true")
|
||||||
|
}
|
||||||
|
if c.mentionsListVisible() {
|
||||||
|
t.Fatal("expected mentionsListVisible to be false")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.setText("/sh", 3)
|
||||||
|
c.tabSuggest()
|
||||||
|
if got := c.commandsList.ItemCount(); got == 0 {
|
||||||
|
t.Fatal("expected command suggestions for '/sh', got none")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModelCommandComplete(t *testing.T) {
|
||||||
|
c, _ := newTestModel(t)
|
||||||
|
channel := discord.Channel{ID: 1, Type: discord.DirectMessage}
|
||||||
|
c.SetChannel(&channel)
|
||||||
|
c.setText("/sh", 3)
|
||||||
|
c.tabSuggest()
|
||||||
|
if !c.commandsListVisible() {
|
||||||
|
t.Fatal("expected commands list visible")
|
||||||
|
}
|
||||||
|
c.tabComplete()
|
||||||
|
if got := c.text; got != "/shrug " {
|
||||||
|
t.Fatalf("text after completion = %q, want '/shrug '", got)
|
||||||
|
}
|
||||||
|
if c.commandsListVisible() {
|
||||||
|
t.Fatal("expected commands list hidden after completion")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@ import (
|
||||||
"github.com/ayn2op/arikawa/v3/gateway"
|
"github.com/ayn2op/arikawa/v3/gateway"
|
||||||
"github.com/ayn2op/arikawa/v3/utils/json/option"
|
"github.com/ayn2op/arikawa/v3/utils/json/option"
|
||||||
"github.com/ayn2op/arikawa/v3/utils/sendpart"
|
"github.com/ayn2op/arikawa/v3/utils/sendpart"
|
||||||
|
"github.com/ayn2op/discordo/internal/command"
|
||||||
|
"github.com/ayn2op/discordo/internal/ui/chat/commandslist"
|
||||||
"github.com/ayn2op/tview"
|
"github.com/ayn2op/tview"
|
||||||
|
"github.com/gdamore/tcell/v3"
|
||||||
"github.com/ncruces/zenity"
|
"github.com/ncruces/zenity"
|
||||||
"github.com/sahilm/fuzzy"
|
"github.com/sahilm/fuzzy"
|
||||||
"golang.design/x/clipboard"
|
"golang.design/x/clipboard"
|
||||||
|
|
@ -30,6 +33,10 @@ type EditLastMsg struct{}
|
||||||
// SentMsg reports that the composer sent a message.
|
// SentMsg reports that the composer sent a message.
|
||||||
type SentMsg struct{}
|
type SentMsg struct{}
|
||||||
|
|
||||||
|
type botCommandsMsg struct {
|
||||||
|
commands []command.Item
|
||||||
|
}
|
||||||
|
|
||||||
type editorMsg string
|
type editorMsg string
|
||||||
|
|
||||||
// edit is a text of the composer and the cursor in it.
|
// edit is a text of the composer and the cursor in it.
|
||||||
|
|
@ -141,6 +148,25 @@ func (m *Model) send() tview.Cmd {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle /nick command
|
||||||
|
if strings.HasPrefix(text, "/nick ") || text == "/nick" {
|
||||||
|
if selectedChannel.GuildID.IsValid() && m.state != nil && m.state.Client != nil {
|
||||||
|
newNick := strings.TrimSpace(strings.TrimPrefix(text, "/nick"))
|
||||||
|
gID := selectedChannel.GuildID
|
||||||
|
m.reset()
|
||||||
|
return func() tview.Msg {
|
||||||
|
if err := m.state.Client.ModifyCurrentMember(gID, newNick); err != nil {
|
||||||
|
slog.Error("failed to change nickname", "guild_id", gID, "err", err)
|
||||||
|
}
|
||||||
|
return SentMsg{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if expanded, ok := command.ExpandBuiltin(text); ok {
|
||||||
|
text = expanded
|
||||||
|
}
|
||||||
|
|
||||||
text = m.processText(selectedChannel, []byte(text))
|
text = m.processText(selectedChannel, []byte(text))
|
||||||
data := *m.sendMessageData
|
data := *m.sendMessageData
|
||||||
data.Files = slices.Clone(data.Files)
|
data.Files = slices.Clone(data.Files)
|
||||||
|
|
@ -168,6 +194,31 @@ func (m *Model) send() tview.Cmd {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) tabComplete() tview.Cmd {
|
func (m *Model) tabComplete() tview.Cmd {
|
||||||
|
if m.commandsListVisible() {
|
||||||
|
return m.completeCommand()
|
||||||
|
}
|
||||||
|
if m.mentionsListVisible() {
|
||||||
|
return m.completeMention()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) completeCommand() tview.Cmd {
|
||||||
|
insertText, ok := m.commandsList.SelectedInsertText()
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
firstSpace := strings.Index(m.text, " ")
|
||||||
|
endPos := m.cursor
|
||||||
|
if firstSpace != -1 {
|
||||||
|
endPos = firstSpace
|
||||||
|
}
|
||||||
|
m.replace(0, endPos, insertText)
|
||||||
|
m.stopTabCompletion()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) completeMention() tview.Cmd {
|
||||||
posEnd, name, r := wordBeforeCursor(m.text, m.cursor, isMentionChar)
|
posEnd, name, r := wordBeforeCursor(m.text, m.cursor, isMentionChar)
|
||||||
if r != '@' {
|
if r != '@' {
|
||||||
m.stopTabCompletion()
|
m.stopTabCompletion()
|
||||||
|
|
@ -220,6 +271,16 @@ func (m *Model) tabComplete() tview.Cmd {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) tabSuggest() tview.Cmd {
|
func (m *Model) tabSuggest() tview.Cmd {
|
||||||
|
if strings.HasPrefix(m.text, "/") {
|
||||||
|
firstSpace := strings.Index(m.text, " ")
|
||||||
|
if firstSpace == -1 || m.cursor <= firstSpace {
|
||||||
|
m.mentionsList.Clear()
|
||||||
|
query := m.text[1:m.cursor]
|
||||||
|
return m.suggestCommands(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.commandsList.Clear()
|
||||||
|
|
||||||
_, name, r := wordBeforeCursor(m.text, m.cursor, isMentionChar)
|
_, name, r := wordBeforeCursor(m.text, m.cursor, isMentionChar)
|
||||||
if r != '@' {
|
if r != '@' {
|
||||||
m.stopTabCompletion()
|
m.stopTabCompletion()
|
||||||
|
|
@ -273,6 +334,82 @@ func (m *Model) tabSuggest() tview.Cmd {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) suggestCommands(query string) tview.Cmd {
|
||||||
|
channel := m.channel
|
||||||
|
if channel == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.commandsList.Clear()
|
||||||
|
|
||||||
|
var userPerms discord.Permissions
|
||||||
|
if channel.GuildID.IsValid() && m.state != nil {
|
||||||
|
if me, err := m.state.Cabinet.Me(); err == nil {
|
||||||
|
if perms, err := m.state.Permissions(channel.ID, me.ID); err == nil {
|
||||||
|
userPerms = perms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
userPerms = discord.PermissionAll
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := int(m.cfg.AutocompleteLimit)
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
results := command.Search(query, channel.ID, userPerms, m.botCommands, limit)
|
||||||
|
for _, item := range results {
|
||||||
|
style := tcell.StyleDefault
|
||||||
|
display := "/" + item.Name
|
||||||
|
if item.Usage != "" {
|
||||||
|
display += " " + item.Usage
|
||||||
|
}
|
||||||
|
if item.Description != "" {
|
||||||
|
display += " - " + item.Description
|
||||||
|
}
|
||||||
|
if item.Source != "" {
|
||||||
|
display += " (" + item.Source + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
m.commandsList.Append(commandslist.Item{
|
||||||
|
Name: item.Name,
|
||||||
|
Usage: item.Usage,
|
||||||
|
Description: item.Description,
|
||||||
|
Source: item.Source,
|
||||||
|
InsertText: "/" + item.Name + " ",
|
||||||
|
DisplayText: display,
|
||||||
|
Style: style,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var fetchCmd tview.Cmd
|
||||||
|
if channel.GuildID.IsValid() && len(m.botCommands) == 0 {
|
||||||
|
fetchCmd = m.fetchBotCommands(channel.ID, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.commandsList.ItemCount() == 0 {
|
||||||
|
m.stopTabCompletion()
|
||||||
|
return fetchCmd
|
||||||
|
}
|
||||||
|
m.commandsList.Rebuild()
|
||||||
|
return fetchCmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) fetchBotCommands(channelID discord.ChannelID, query string) tview.Cmd {
|
||||||
|
if m.state == nil || m.state.Client == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
client := m.state.Client
|
||||||
|
return func() tview.Msg {
|
||||||
|
items, err := command.FetchBotCommands(context.Background(), client, channelID, query)
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("failed to fetch bot commands", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return botCommandsMsg{commands: items}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// searchMember performs member discovery in a command goroutine.
|
// searchMember performs member discovery in a command goroutine.
|
||||||
// It emits a follow-up suggestion message once results are loaded.
|
// It emits a follow-up suggestion message once results are loaded.
|
||||||
func (m *Model) searchMember(gID discord.GuildID, name string) tview.Cmd {
|
func (m *Model) searchMember(gID discord.GuildID, name string) tview.Cmd {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import (
|
||||||
"github.com/ayn2op/discordo/internal/ui"
|
"github.com/ayn2op/discordo/internal/ui"
|
||||||
"github.com/ayn2op/discordo/internal/ui/chat/attachmentspicker"
|
"github.com/ayn2op/discordo/internal/ui/chat/attachmentspicker"
|
||||||
"github.com/ayn2op/discordo/internal/ui/chat/channelspicker"
|
"github.com/ayn2op/discordo/internal/ui/chat/channelspicker"
|
||||||
|
"github.com/ayn2op/discordo/internal/ui/chat/commandslist"
|
||||||
"github.com/ayn2op/discordo/internal/ui/chat/composer"
|
"github.com/ayn2op/discordo/internal/ui/chat/composer"
|
||||||
"github.com/ayn2op/discordo/internal/ui/chat/guildstree"
|
"github.com/ayn2op/discordo/internal/ui/chat/guildstree"
|
||||||
"github.com/ayn2op/discordo/internal/ui/chat/memberstree"
|
"github.com/ayn2op/discordo/internal/ui/chat/memberstree"
|
||||||
|
|
@ -304,7 +305,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||||
ui.DialogButton{Label: "Yes", Cmd: tview.Sequence(closeState(m.state), logout())},
|
ui.DialogButton{Label: "Yes", Cmd: tview.Sequence(closeState(m.state), logout())},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case composer.TabSuggestMsg, mentionslist.Msg:
|
case composer.TabSuggestMsg, mentionslist.Msg, commandslist.Msg:
|
||||||
return m, m.updatePane(paneComposer, msg)
|
return m, m.updatePane(paneComposer, msg)
|
||||||
case paneMsg:
|
case paneMsg:
|
||||||
if msg.focus {
|
if msg.focus {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue