Compare commits
1 commit
feat/emoji
...
feat/comma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
778c3c4423 |
12 changed files with 498 additions and 2214 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
package emoji
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/ayn2op/arikawa/v3/discord"
|
||||
"github.com/sahilm/fuzzy"
|
||||
)
|
||||
|
||||
type Item struct {
|
||||
Name string
|
||||
InsertText string
|
||||
Display string
|
||||
Custom bool
|
||||
}
|
||||
|
||||
type itemList []Item
|
||||
|
||||
func (l itemList) String(i int) string {
|
||||
return l[i].Name
|
||||
}
|
||||
|
||||
func (l itemList) Len() int {
|
||||
return len(l)
|
||||
}
|
||||
|
||||
var unicodeList = sync.OnceValue(func() []Item {
|
||||
items := make([]Item, 0, len(Unicode))
|
||||
for name, glyph := range Unicode {
|
||||
items = append(items, Item{
|
||||
Name: name,
|
||||
InsertText: glyph,
|
||||
Display: ":" + name + ": " + glyph,
|
||||
Custom: false,
|
||||
})
|
||||
}
|
||||
slices.SortFunc(items, func(a, b Item) int {
|
||||
if a.Name < b.Name {
|
||||
return -1
|
||||
} else if a.Name > b.Name {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
return items
|
||||
})
|
||||
|
||||
// Search searches custom emojis and standard Unicode emojis matching the query.
|
||||
func Search(query string, custom []discord.Emoji, limit int) []Item {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
customItems := make([]Item, 0, len(custom))
|
||||
for _, e := range custom {
|
||||
customItems = append(customItems, Item{
|
||||
Name: e.Name,
|
||||
InsertText: e.String(),
|
||||
Display: ":" + e.Name + ":",
|
||||
Custom: true,
|
||||
})
|
||||
}
|
||||
|
||||
uItems := unicodeList()
|
||||
|
||||
if query == "" {
|
||||
results := make([]Item, 0, limit)
|
||||
results = append(results, customItems...)
|
||||
if len(results) < limit {
|
||||
needed := limit - len(results)
|
||||
if needed > len(uItems) {
|
||||
needed = len(uItems)
|
||||
}
|
||||
results = append(results, uItems[:needed]...)
|
||||
}
|
||||
if len(results) > limit {
|
||||
return results[:limit]
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// Exact prefix matches on custom emojis get highest priority
|
||||
var results []Item
|
||||
lowerQuery := strings.ToLower(query)
|
||||
|
||||
for _, item := range customItems {
|
||||
if strings.HasPrefix(strings.ToLower(item.Name), lowerQuery) {
|
||||
results = append(results, item)
|
||||
if len(results) >= limit {
|
||||
return results
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fuzzy search across remaining custom emojis
|
||||
if len(customItems) > 0 {
|
||||
customMatches := fuzzy.FindFrom(query, itemList(customItems))
|
||||
for _, m := range customMatches {
|
||||
item := customItems[m.Index]
|
||||
if !slices.ContainsFunc(results, func(it Item) bool { return it.Name == item.Name && it.Custom }) {
|
||||
results = append(results, item)
|
||||
if len(results) >= limit {
|
||||
return results
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exact prefix matches on unicode emojis
|
||||
for _, item := range uItems {
|
||||
if strings.HasPrefix(strings.ToLower(item.Name), lowerQuery) {
|
||||
results = append(results, item)
|
||||
if len(results) >= limit {
|
||||
return results
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fuzzy search across unicode emojis
|
||||
uMatches := fuzzy.FindFrom(query, itemList(uItems))
|
||||
for _, m := range uMatches {
|
||||
item := uItems[m.Index]
|
||||
if !slices.ContainsFunc(results, func(it Item) bool { return it.Name == item.Name && !it.Custom }) {
|
||||
results = append(results, item)
|
||||
if len(results) >= limit {
|
||||
return results
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
package emoji
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ayn2op/arikawa/v3/discord"
|
||||
)
|
||||
|
||||
func TestSearchUnicode(t *testing.T) {
|
||||
results := Search("smile", nil, 5)
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected results for 'smile', got none")
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, item := range results {
|
||||
if item.Name == "smile" && item.InsertText == "😄" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected smile -> 😄 in results, got %+v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchCustomEmojis(t *testing.T) {
|
||||
custom := []discord.Emoji{
|
||||
{
|
||||
ID: 123456789,
|
||||
Name: "custom_cat",
|
||||
},
|
||||
{
|
||||
ID: 987654321,
|
||||
Name: "pepe_dance",
|
||||
Animated: true,
|
||||
},
|
||||
}
|
||||
|
||||
results := Search("cat", custom, 5)
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected results for 'cat', got none")
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, item := range results {
|
||||
if item.Name == "custom_cat" {
|
||||
found = true
|
||||
if item.InsertText != "<:custom_cat:123456789>" {
|
||||
t.Errorf("expected custom format, got %s", item.InsertText)
|
||||
}
|
||||
if !item.Custom {
|
||||
t.Errorf("expected Custom=true")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected custom_cat in results, got %+v", results)
|
||||
}
|
||||
|
||||
// Test animated emoji
|
||||
animResults := Search("dance", custom, 5)
|
||||
if len(animResults) == 0 {
|
||||
t.Fatal("expected results for 'dance', got none")
|
||||
}
|
||||
if animResults[0].InsertText != "<a:pepe_dance:987654321>" {
|
||||
t.Errorf("expected animated format, got %s", animResults[0].InsertText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchEmptyQuery(t *testing.T) {
|
||||
custom := []discord.Emoji{
|
||||
{
|
||||
ID: 111,
|
||||
Name: "alpha",
|
||||
},
|
||||
}
|
||||
results := Search("", custom, 10)
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected results for empty query, got none")
|
||||
}
|
||||
// Custom emojis should be listed first
|
||||
if results[0].Name != "alpha" {
|
||||
t.Errorf("expected custom emoji first for empty query, got %s", results[0].Name)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,9 +1,12 @@
|
|||
package emojislist
|
||||
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
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package emojislist
|
||||
package commandslist
|
||||
|
||||
import (
|
||||
"github.com/ayn2op/discordo/internal/config"
|
||||
|
|
@ -12,7 +12,7 @@ import (
|
|||
"github.com/rockorager/go-uucode"
|
||||
)
|
||||
|
||||
// Model shows emoji suggestions as a list in a box.
|
||||
// Model shows command suggestions as a list in a box.
|
||||
type Model struct {
|
||||
cfg *config.Config
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ func (m Model) View() tview.Widget {
|
|||
list, width = list.Width(layout.Fixed(max(int(cfg.MinWidth), m.maxDisplayWidth()))), layout.Shrink
|
||||
}
|
||||
|
||||
return ui.Box(list, m.cfg, false).Title(ui.Label("Emoji", m.cfg.Theme.Title, false)).BorderSet(set).Width(width).Height(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 }
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package emojislist
|
||||
package commandslist
|
||||
|
||||
import "github.com/ayn2op/tview/viewport"
|
||||
|
||||
|
|
@ -22,10 +22,11 @@ import (
|
|||
"github.com/ayn2op/arikawa/v3/state"
|
||||
"github.com/ayn2op/arikawa/v3/utils/json/option"
|
||||
"github.com/ayn2op/arikawa/v3/utils/sendpart"
|
||||
"github.com/ayn2op/discordo/internal/command"
|
||||
"github.com/ayn2op/discordo/internal/config"
|
||||
"github.com/ayn2op/discordo/internal/consts"
|
||||
"github.com/ayn2op/discordo/internal/ui"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/emojislist"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/commandslist"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/mentionslist"
|
||||
"github.com/ayn2op/ningen/v3"
|
||||
"github.com/ayn2op/tview"
|
||||
|
|
@ -72,7 +73,8 @@ type Model struct {
|
|||
sendMessageData *api.SendMessageData
|
||||
memberSearchCache map[string]uint
|
||||
mentionsList mentionslist.Model
|
||||
emojisList emojislist.Model
|
||||
commandsList commandslist.Model
|
||||
botCommands []command.Item
|
||||
lastSearch time.Time
|
||||
|
||||
typingUntil time.Time
|
||||
|
|
@ -89,14 +91,14 @@ func NewModel(cfg *config.Config, state *ningen.State) Model {
|
|||
sendMessageData: &api.SendMessageData{},
|
||||
memberSearchCache: make(map[string]uint),
|
||||
mentionsList: mentionslist.NewModel(cfg),
|
||||
emojisList: emojislist.NewModel(cfg),
|
||||
commandsList: commandslist.NewModel(cfg),
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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
|
||||
|
||||
// Sending in a thread takes another permission than in the channel it is in.
|
||||
|
|
@ -269,7 +271,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
|||
m.pasteText()
|
||||
return m, pasteImage()
|
||||
case keybind.Matches(msg, kbs.Send.Keybind):
|
||||
if m.mentionsListVisible() || m.emojisListVisible() {
|
||||
if m.mentionsListVisible() || m.commandsListVisible() {
|
||||
return m, m.tabComplete()
|
||||
}
|
||||
return m, m.send()
|
||||
|
|
@ -280,14 +282,14 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
|||
m.stopTabCompletion()
|
||||
return m, m.pickFiles()
|
||||
case keybind.Matches(msg, kbs.Cancel.Keybind):
|
||||
if m.mentionsListVisible() || m.emojisListVisible() {
|
||||
if m.mentionsListVisible() || m.commandsListVisible() {
|
||||
m.stopTabCompletion()
|
||||
return m, nil
|
||||
}
|
||||
m.reset()
|
||||
return m, nil
|
||||
case keybind.Matches(msg, kbs.TabComplete.Keybind):
|
||||
if m.mentionsListVisible() || m.emojisListVisible() {
|
||||
if m.mentionsListVisible() || m.commandsListVisible() {
|
||||
return m, m.tabComplete()
|
||||
}
|
||||
m.insert("\t")
|
||||
|
|
@ -303,10 +305,17 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
|||
var cmd tview.Cmd
|
||||
m.mentionsList, cmd = m.mentionsList.Update(msg)
|
||||
return m, cmd
|
||||
case emojislist.Msg:
|
||||
case commandslist.Msg:
|
||||
var cmd tview.Cmd
|
||||
m.emojisList, cmd = m.emojisList.Update(msg)
|
||||
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
|
||||
}
|
||||
|
|
@ -314,7 +323,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
|||
func (m Model) canEditLastMessage() bool {
|
||||
return !m.Disabled() && m.editing == nil && m.text == "" &&
|
||||
m.sendMessageData.Reference == nil && len(m.sendMessageData.Files) == 0 &&
|
||||
!m.mentionsListVisible() && !m.emojisListVisible()
|
||||
!m.mentionsListVisible() && !m.commandsListVisible()
|
||||
}
|
||||
|
||||
// StartEdit puts message in the composer to be edited.
|
||||
|
|
@ -428,10 +437,6 @@ func isMentionChar(r rune) bool {
|
|||
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '.'
|
||||
}
|
||||
|
||||
func isEmojiChar(r rune) bool {
|
||||
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '+' || r == '-'
|
||||
}
|
||||
|
||||
// suggestRecentAuthors suggests the authors of the channel's recent messages, other than the user.
|
||||
func (m *Model) suggestRecentAuthors(channel *discord.Channel) {
|
||||
messages, err := m.state.Cabinet.Messages(channel.ID)
|
||||
|
|
@ -511,17 +516,17 @@ func (m Model) OnGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
// AutocompleteView places the active completion list (mentions or emojis) 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.
|
||||
// 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) AutocompleteView(focused bool) tview.Widget {
|
||||
if !focused {
|
||||
return nil
|
||||
}
|
||||
if m.commandsListVisible() {
|
||||
return commandsPopup{m}
|
||||
}
|
||||
if m.mentionsListVisible() {
|
||||
return mentionsPopup{m}
|
||||
}
|
||||
if m.emojisListVisible() {
|
||||
return emojisPopup{m}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -561,30 +566,28 @@ 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}
|
||||
}
|
||||
|
||||
type emojisPopup struct {
|
||||
type commandsPopup struct {
|
||||
c Model
|
||||
}
|
||||
|
||||
func (emojisPopup) Size() (width, height layout.Length) { return layout.Fill, layout.Fill }
|
||||
func (commandsPopup) Size() (width, height layout.Length) { return layout.Fill, layout.Fill }
|
||||
|
||||
func (emojisPopup) Layout(limits layout.Limits) layout.Size {
|
||||
func (commandsPopup) Layout(limits layout.Limits) layout.Size {
|
||||
return layout.Atomic(limits, layout.Fill, layout.Fill)
|
||||
}
|
||||
|
||||
func (p emojisPopup) Draw(screen tview.Screen, area tview.Rectangle) {
|
||||
p.c.emojisList.View().Draw(screen, p.area(area))
|
||||
func (p commandsPopup) Draw(screen tview.Screen, area tview.Rectangle) {
|
||||
p.c.commandsList.View().Draw(screen, p.area(area))
|
||||
}
|
||||
|
||||
func (p emojisPopup) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg {
|
||||
return p.c.emojisList.View().Handle(msg, 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 emojisPopup) area(area tview.Rectangle) tview.Rectangle {
|
||||
func (p commandsPopup) area(area tview.Rectangle) tview.Rectangle {
|
||||
c := p.c
|
||||
size := c.emojisList.View().Layout(layout.Limits{Max: layout.Size{Width: area.Width, Height: area.Height}})
|
||||
value, cursor := c.text, c.cursor
|
||||
col := uucode.StringWidth(value[strings.LastIndex(value[:cursor], "\n")+1 : cursor])
|
||||
x := min(c.box(false).InnerArea(tview.Rectangle{}).X+col, area.Width-size.Width)
|
||||
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}
|
||||
}
|
||||
|
||||
|
|
@ -646,7 +649,7 @@ func (m *Model) addMentionUser(user *discord.User) {
|
|||
func (m *Model) stopTabCompletion() {
|
||||
if m.cfg.AutocompleteLimit > 0 {
|
||||
m.mentionsList.Clear()
|
||||
m.emojisList.Clear()
|
||||
m.commandsList.Clear()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -655,8 +658,8 @@ func (m Model) mentionsListVisible() bool {
|
|||
return m.mentionsList.ItemCount() != 0
|
||||
}
|
||||
|
||||
func (m Model) emojisListVisible() bool {
|
||||
return m.emojisList.ItemCount() != 0
|
||||
func (m Model) commandsListVisible() bool {
|
||||
return m.commandsList.ItemCount() != 0
|
||||
}
|
||||
|
||||
func (m Model) attach(name string, reader io.Reader) {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import (
|
|||
"github.com/ayn2op/arikawa/v3/utils/sendpart"
|
||||
"github.com/ayn2op/discordo/internal/config"
|
||||
"github.com/ayn2op/ningen/v3"
|
||||
ningenEmoji "github.com/ayn2op/ningen/v3/states/emoji"
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/ayn2op/tview/keybind"
|
||||
"github.com/gdamore/tcell/v3"
|
||||
|
|
@ -202,62 +201,43 @@ func TestModelProcessText(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestModelEmojiSuggest(t *testing.T) {
|
||||
func TestModelCommandSuggest(t *testing.T) {
|
||||
c, _ := newTestModel(t)
|
||||
channel := discord.Channel{ID: 1, Type: discord.DirectMessage}
|
||||
c.SetChannel(&channel)
|
||||
c.setText(":smi", 4)
|
||||
c.setText("/", 1)
|
||||
c.tabSuggest()
|
||||
if got := c.emojisList.ItemCount(); got == 0 {
|
||||
t.Fatal("expected emoji suggestions for ':smi', got none")
|
||||
if got := c.commandsList.ItemCount(); got == 0 {
|
||||
t.Fatal("expected command suggestions for '/', got none")
|
||||
}
|
||||
if !c.emojisListVisible() {
|
||||
t.Fatal("expected emojisListVisible to be true")
|
||||
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 TestModelEmojiComplete(t *testing.T) {
|
||||
func TestModelCommandComplete(t *testing.T) {
|
||||
c, _ := newTestModel(t)
|
||||
channel := discord.Channel{ID: 1, Type: discord.DirectMessage}
|
||||
c.SetChannel(&channel)
|
||||
c.setText("hello :smile", 12)
|
||||
c.setText("/sh", 3)
|
||||
c.tabSuggest()
|
||||
if !c.emojisListVisible() {
|
||||
t.Fatal("expected emojis list visible")
|
||||
if !c.commandsListVisible() {
|
||||
t.Fatal("expected commands list visible")
|
||||
}
|
||||
c.tabComplete()
|
||||
if got := c.text; got != "hello 😄 " {
|
||||
t.Fatalf("text after completion = %q, want 'hello 😄 '", got)
|
||||
if got := c.text; got != "/shrug " {
|
||||
t.Fatalf("text after completion = %q, want '/shrug '", got)
|
||||
}
|
||||
if c.emojisListVisible() {
|
||||
t.Fatal("expected emojis list to be hidden after completion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelCustomEmojiComplete(t *testing.T) {
|
||||
c, _ := newTestModel(t)
|
||||
guildID := discord.GuildID(100)
|
||||
channel := discord.Channel{ID: 10, GuildID: guildID, Type: discord.GuildText}
|
||||
c.state.EmojiState = ningenEmoji.NewState(c.state.Cabinet)
|
||||
_ = c.state.Cabinet.GuildSet(&discord.Guild{ID: guildID}, false)
|
||||
_ = c.state.Cabinet.EmojiSet(guildID, []discord.Emoji{
|
||||
{ID: 555, Name: "custom_pepe"},
|
||||
}, false)
|
||||
|
||||
c.SetChannel(&channel)
|
||||
c.setText(":custom_pepe", 12)
|
||||
c.tabSuggest()
|
||||
if !c.emojisListVisible() {
|
||||
t.Fatal("expected emojis list visible")
|
||||
}
|
||||
c.tabComplete()
|
||||
if got := c.text; got != "<:custom_pepe:555> " {
|
||||
t.Fatalf("text after completion = %q, want '<:custom_pepe:555> '", got)
|
||||
}
|
||||
if c.emojisListVisible() {
|
||||
t.Fatal("expected emojis list to be hidden after completion")
|
||||
if c.commandsListVisible() {
|
||||
t.Fatal("expected commands list hidden after completion")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ import (
|
|||
"github.com/ayn2op/arikawa/v3/gateway"
|
||||
"github.com/ayn2op/arikawa/v3/utils/json/option"
|
||||
"github.com/ayn2op/arikawa/v3/utils/sendpart"
|
||||
"github.com/ayn2op/discordo/internal/emoji"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/emojislist"
|
||||
"github.com/ayn2op/discordo/internal/command"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/commandslist"
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/gdamore/tcell/v3"
|
||||
"github.com/ncruces/zenity"
|
||||
|
|
@ -33,6 +33,10 @@ type EditLastMsg struct{}
|
|||
// SentMsg reports that the composer sent a message.
|
||||
type SentMsg struct{}
|
||||
|
||||
type botCommandsMsg struct {
|
||||
commands []command.Item
|
||||
}
|
||||
|
||||
type editorMsg string
|
||||
|
||||
// edit is a text of the composer and the cursor in it.
|
||||
|
|
@ -144,6 +148,25 @@ func (m *Model) send() tview.Cmd {
|
|||
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))
|
||||
data := *m.sendMessageData
|
||||
data.Files = slices.Clone(data.Files)
|
||||
|
|
@ -171,12 +194,27 @@ func (m *Model) send() tview.Cmd {
|
|||
}
|
||||
|
||||
func (m *Model) tabComplete() tview.Cmd {
|
||||
if m.commandsListVisible() {
|
||||
return m.completeCommand()
|
||||
}
|
||||
if m.mentionsListVisible() {
|
||||
return m.completeMention()
|
||||
}
|
||||
if m.emojisListVisible() {
|
||||
return m.completeEmoji()
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -232,41 +270,22 @@ func (m *Model) completeMention() tview.Cmd {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) completeEmoji() tview.Cmd {
|
||||
posEnd, name, r := wordBeforeCursor(m.text, m.cursor, isEmojiChar)
|
||||
if r != ':' {
|
||||
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)
|
||||
if r != '@' {
|
||||
m.stopTabCompletion()
|
||||
return nil
|
||||
}
|
||||
pos := posEnd - (len(name) + 1)
|
||||
|
||||
insertText, ok := m.emojisList.SelectedInsertText()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
m.replace(pos, posEnd, insertText+" ")
|
||||
m.stopTabCompletion()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) tabSuggest() tview.Cmd {
|
||||
_, mentionName, rMention := wordBeforeCursor(m.text, m.cursor, isMentionChar)
|
||||
if rMention == '@' {
|
||||
m.emojisList.Clear()
|
||||
return m.suggestMentions(mentionName)
|
||||
}
|
||||
|
||||
_, emojiName, rEmoji := wordBeforeCursor(m.text, m.cursor, isEmojiChar)
|
||||
if rEmoji == ':' {
|
||||
m.mentionsList.Clear()
|
||||
return m.suggestEmojis(emojiName)
|
||||
}
|
||||
|
||||
m.stopTabCompletion()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) suggestMentions(name string) tview.Cmd {
|
||||
channel := m.channel
|
||||
if channel == nil {
|
||||
return nil
|
||||
|
|
@ -315,23 +334,22 @@ func (m *Model) suggestMentions(name string) tview.Cmd {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) suggestEmojis(name string) tview.Cmd {
|
||||
func (m *Model) suggestCommands(query string) tview.Cmd {
|
||||
channel := m.channel
|
||||
if channel == nil {
|
||||
return nil
|
||||
}
|
||||
m.emojisList.Clear()
|
||||
m.commandsList.Clear()
|
||||
|
||||
var customEmojis []discord.Emoji
|
||||
if m.state != nil && m.state.EmojiState != nil {
|
||||
guilds, err := m.state.EmojiState.ForGuild(channel.GuildID)
|
||||
if err == nil {
|
||||
for _, g := range guilds {
|
||||
customEmojis = append(customEmojis, g.Emojis...)
|
||||
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 {
|
||||
slog.Debug("failed to get emojis for guild", "err", err)
|
||||
}
|
||||
} else {
|
||||
userPerms = discord.PermissionAll
|
||||
}
|
||||
|
||||
limit := int(m.cfg.AutocompleteLimit)
|
||||
|
|
@ -339,23 +357,57 @@ func (m *Model) suggestEmojis(name string) tview.Cmd {
|
|||
limit = 20
|
||||
}
|
||||
|
||||
results := emoji.Search(name, customEmojis, limit)
|
||||
results := command.Search(query, channel.ID, userPerms, m.botCommands, limit)
|
||||
for _, item := range results {
|
||||
style := tcell.StyleDefault
|
||||
m.emojisList.Append(emojislist.Item{
|
||||
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,
|
||||
InsertText: item.InsertText,
|
||||
DisplayText: item.Display,
|
||||
Usage: item.Usage,
|
||||
Description: item.Description,
|
||||
Source: item.Source,
|
||||
InsertText: "/" + item.Name + " ",
|
||||
DisplayText: display,
|
||||
Style: style,
|
||||
})
|
||||
}
|
||||
|
||||
if m.emojisList.ItemCount() == 0 {
|
||||
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
|
||||
}
|
||||
m.emojisList.Rebuild()
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ import (
|
|||
"github.com/ayn2op/discordo/internal/ui"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/attachmentspicker"
|
||||
"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/emojislist"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/guildstree"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/memberstree"
|
||||
"github.com/ayn2op/discordo/internal/ui/chat/mentionslist"
|
||||
|
|
@ -305,7 +305,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
|||
ui.DialogButton{Label: "Yes", Cmd: tview.Sequence(closeState(m.state), logout())},
|
||||
)
|
||||
}
|
||||
case composer.TabSuggestMsg, mentionslist.Msg, emojislist.Msg:
|
||||
case composer.TabSuggestMsg, mentionslist.Msg, commandslist.Msg:
|
||||
return m, m.updatePane(paneComposer, msg)
|
||||
case paneMsg:
|
||||
if msg.focus {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue