Skip to content

Commit b64b106

Browse files
Merge pull request #282 from itsneufox/save-roles
Track roles (re-adds them if user rejoins) and /wiki improvements
2 parents 4b8d69a + 19c2f7c commit b64b106

File tree

5 files changed

+348
-32
lines changed

5 files changed

+348
-32
lines changed

bot/commands/cmd_wiki.go

Lines changed: 173 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,20 @@ const (
2727
)
2828

2929
func truncateText(s string, max int) string {
30+
if s == "" {
31+
return "Documentation available - click to view"
32+
}
3033
if max > len(s) {
3134
return s
3235
}
33-
return s[:strings.LastIndex(s[:max], " ")] + " ..."
36+
37+
// Find last space before max length
38+
truncated := s[:max]
39+
if lastSpace := strings.LastIndex(truncated, " "); lastSpace > max/2 {
40+
return truncated[:lastSpace] + "..."
41+
}
42+
43+
return truncated + "..."
3444
}
3545

3646
func (cm *CommandManager) commandWiki(
@@ -61,7 +71,7 @@ func (cm *CommandManager) commandWiki(
6171
search.NewEmptySearchForHits().
6272
SetIndexName(algoliaIndexName).
6373
SetQuery(searchTerm).
64-
SetHitsPerPage(3).
74+
SetHitsPerPage(5).
6575
SetFilters("language:en"),
6676
)},
6777
),
@@ -78,6 +88,7 @@ func (cm *CommandManager) commandWiki(
7888
Type: discordgo.EmbedTypeRich,
7989
Title: fmt.Sprintf("No results: %s", searchTerm),
8090
Description: "There were no results for that query.",
91+
Color: 0xED4245, // Red color for no results
8192
})
8293
return
8394
}
@@ -102,61 +113,194 @@ func (cm *CommandManager) commandWiki(
102113
continue
103114
}
104115

105-
if seenUrls[hitData["url_without_anchor"].(string)] { //Already presented to user - Algolia does this thing of sending twice the same thing
116+
// Get URL
117+
urlWithoutAnchor, ok := hitData["url_without_anchor"].(string)
118+
if !ok {
106119
continue
107120
}
108121

109-
seenUrls[hitData["url_without_anchor"].(string)] = true
122+
// Skip if already processed this URL - Algolia does this thing of sending twice the same thing
123+
if seenUrls[urlWithoutAnchor] {
124+
continue
125+
}
126+
seenUrls[urlWithoutAnchor] = true
110127

111-
stringParts := strings.Split(strings.TrimSuffix(hitData["url_without_anchor"].(string), "/"), "/") //Algolia doesnt give the Function/Callback name - so I steal it from the URL
128+
// Parse URL to get page info - Algolia doesn't give the Function/Callback name - so I steal it from the URL
129+
stringParts := strings.Split(strings.TrimSuffix(urlWithoutAnchor, "/"), "/")
130+
if len(stringParts) < 3 {
131+
continue
132+
}
112133

113-
if stringParts[len(stringParts)-2] == "blog" { //Remove blog posts from results
134+
// Skip blog posts and other non-documentation pages - Remove blog posts from results
135+
if len(stringParts) >= 4 && (stringParts[len(stringParts)-2] == "blog" ||
136+
stringParts[len(stringParts)-3] == "blog") {
114137
continue
115138
}
116139

117140
actuallyFoundResults++
118141

119-
content, ok := hitData["content"].(string)
120-
description := ""
121-
if !ok {
122-
description = "(No description found)"
123-
} else {
124-
description = formatDescription(&content)
125-
}
142+
// Get page/function name
143+
pageName := stringParts[len(stringParts)-1]
144+
145+
// Get description from multiple possible sources
146+
description := extractDescription(hitData)
147+
148+
// Build category path
149+
category := buildCategory(stringParts)
126150

151+
// Format the result
127152
desc.WriteString(fmt.Sprintf(
128-
"[%s](%s) [%s/%s]: %s\n",
129-
stringParts[len(stringParts)-1],
130-
hitData["url_without_anchor"].(string),
131-
stringParts[len(stringParts)-3],
132-
stringParts[len(stringParts)-2],
133-
truncateText(description, 200),
153+
"[**%s**](%s)\n%s: %s\n\n",
154+
pageName,
155+
urlWithoutAnchor,
156+
category,
157+
truncateText(description, 120),
134158
))
159+
160+
// Limit to 3 results for readability
161+
if actuallyFoundResults >= 3 {
162+
break
163+
}
135164
}
136165

137-
if actuallyFoundResults == 0 { //Hitting this means all results were filtered out
166+
// Hitting this means all results were filtered out
167+
if actuallyFoundResults == 0 {
138168
cm.replyDirectlyEmbed(interaction, "", &discordgo.MessageEmbed{
139169
Type: discordgo.EmbedTypeRich,
140170
Title: fmt.Sprintf("No results: %s", searchTerm),
141-
Description: "There were no results for that query.",
171+
Description: "No documentation found for that query. Try a different search term.",
172+
Color: 0xED4245,
142173
})
143174
return
144175
}
145176

146177
embed := &discordgo.MessageEmbed{
147178
Type: discordgo.EmbedTypeRich,
148-
Title: fmt.Sprintf("Documentation Search Results: %s", searchTerm),
179+
Title: fmt.Sprintf("Documentation Search Results for %s", searchTerm),
149180
Description: desc.String(),
181+
Color: 0x5865F2, // Discord blurple
182+
Footer: &discordgo.MessageEmbedFooter{
183+
Text: "Click the links to view full documentation",
184+
},
150185
}
151186
cm.replyDirectlyEmbed(interaction, "", embed)
152187

153-
return false, err // Todo: remove this
188+
return false, err
189+
}
190+
191+
func extractDescription(hitData map[string]interface{}) string {
192+
// Try to get description from various fields in order of preference
193+
descriptionSources := []string{
194+
"description", // Most likely to have the frontmatter description
195+
"content", // Page content
196+
"excerpt", // Page excerpt
197+
"text", // Text content
198+
"summary", // Summary
199+
}
200+
201+
for _, field := range descriptionSources {
202+
if value, exists := hitData[field]; exists {
203+
if strValue, ok := value.(string); ok && strings.TrimSpace(strValue) != "" {
204+
cleaned := cleanDescription(strValue)
205+
if cleaned != "" {
206+
return cleaned
207+
}
208+
}
209+
}
210+
}
211+
212+
// Try hierarchy for context
213+
if hierarchy, exists := hitData["hierarchy"]; exists {
214+
if hierarchyMap, ok := hierarchy.(map[string]interface{}); ok {
215+
// Try different hierarchy levels
216+
for _, level := range []string{"lvl0", "lvl1", "lvl2", "lvl3"} {
217+
if lvlValue, exists := hierarchyMap[level]; exists {
218+
if strValue, ok := lvlValue.(string); ok && strings.TrimSpace(strValue) != "" {
219+
return cleanDescription(strValue)
220+
}
221+
}
222+
}
223+
}
224+
}
225+
226+
// Try anchor text
227+
if anchor, exists := hitData["anchor"]; exists {
228+
if strValue, ok := anchor.(string); ok && strings.TrimSpace(strValue) != "" {
229+
return cleanDescription(strValue)
230+
}
231+
}
232+
233+
return "Documentation available"
154234
}
155235

156-
func formatDescription(hit *string) string {
157-
return html.UnescapeString(strings.ReplaceAll(
158-
strings.ReplaceAll(
159-
*hit,
160-
"<mark>", "**"),
161-
"</mark>", "**"))
236+
func cleanDescription(text string) string {
237+
if text == "" {
238+
return ""
239+
}
240+
241+
// Remove HTML tags and decode entities
242+
cleaned := html.UnescapeString(text)
243+
244+
// Remove markdown-style highlighting
245+
cleaned = strings.ReplaceAll(cleaned, "<mark>", "**")
246+
cleaned = strings.ReplaceAll(cleaned, "</mark>", "**")
247+
248+
// Clean up whitespace and newlines
249+
cleaned = strings.ReplaceAll(cleaned, "\n", " ")
250+
cleaned = strings.ReplaceAll(cleaned, "\r", " ")
251+
252+
// Remove multiple spaces
253+
for strings.Contains(cleaned, " ") {
254+
cleaned = strings.ReplaceAll(cleaned, " ", " ")
255+
}
256+
257+
cleaned = strings.TrimSpace(cleaned)
258+
259+
// Remove common prefixes that aren't useful
260+
prefixesToRemove := []string{
261+
"Description:",
262+
"Description",
263+
"## Description",
264+
"###",
265+
"##",
266+
"#",
267+
}
268+
269+
for _, prefix := range prefixesToRemove {
270+
if strings.HasPrefix(cleaned, prefix) {
271+
cleaned = strings.TrimSpace(strings.TrimPrefix(cleaned, prefix))
272+
}
273+
}
274+
275+
return cleaned
162276
}
277+
278+
func buildCategory(urlParts []string) string {
279+
if len(urlParts) < 4 {
280+
return "Documentation"
281+
}
282+
283+
// Extract meaningful category parts
284+
var categoryParts []string
285+
286+
// Skip the domain parts and get the meaningful path
287+
for i := 3; i < len(urlParts)-1; i++ {
288+
part := urlParts[i]
289+
290+
// Skip common path parts that aren't useful
291+
if part == "docs" || part == "en" || part == "" {
292+
continue
293+
}
294+
295+
// Capitalize and clean up the part
296+
part = strings.ReplaceAll(part, "-", " ")
297+
part = strings.Title(part)
298+
categoryParts = append(categoryParts, part)
299+
}
300+
301+
if len(categoryParts) == 0 {
302+
return "Documentation"
303+
}
304+
305+
return strings.Join(categoryParts, " › ")
306+
}

bot/discord.go

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,22 @@ type ChannelDM struct {
1818
LastMessageID string `json:"last_message_id"`
1919
}
2020

21+
// Define the roles that we want to track
22+
var trackedRoleIDs = map[string]string{
23+
"1002922725553217648": "Clown",
24+
"400250542628274177": "Caged",
25+
"995816487610753094": "annoyed me",
26+
"1016047260364198008": "Not Cool",
27+
"833325019252785173": "Doesnt deserve to embed",
28+
"818457955690872832": "Doesnt deserve to react",
29+
"996883259252297758": "No open.mp support",
30+
"910950457680212088": "No Server Adverts",
31+
"841368374356738078": "Suffers from dunning-kruger",
32+
"987825514511220867": "Muted",
33+
"1204891485867352144": "Can't @everyone",
34+
35+
}
36+
2137
const greeting = `Hi! Welcome to the San Andreas Multiplayer unofficial Discord server!
2238
2339
Please read the rules and be respectful.`
@@ -35,6 +51,7 @@ func (app *App) ConnectDiscord() (err error) {
3551
app.discordClient.S.AddHandler(app.onJoin)
3652
app.discordClient.S.AddHandler(app.onReactionAdd)
3753
app.discordClient.S.AddHandler(app.onReactionRemove)
54+
app.discordClient.S.AddHandler(app.onGuildMemberUpdate)
3855

3956
intent := discordgo.MakeIntent(discordgo.IntentsAllWithoutPrivileged | discordgo.IntentsGuildMembers)
4057

@@ -56,6 +73,58 @@ func (app *App) ConnectDiscord() (err error) {
5673
return
5774
}
5875

76+
// nolint:gocyclo
77+
func (app *App) onGuildMemberUpdate(s *discordgo.Session, event *discordgo.GuildMemberUpdate) {
78+
member := event.Member
79+
80+
// Get user's current tracked roles
81+
user, err := app.storage.GetUserOrCreate(member.User.ID)
82+
if err != nil {
83+
zap.L().Error("failed to get user for role tracking", zap.Error(err))
84+
return
85+
}
86+
87+
// Check current Discord roles aginst our tracked role list
88+
currentTrackedRoles := make(map[string]bool)
89+
for _, roleID := range member.Roles {
90+
if roleName, isTracked := trackedRoleIDs[roleID]; isTracked {
91+
currentTrackedRoles[roleID] = true
92+
93+
hasRole := false
94+
for _, trackedRole := range user.TrackedRoles {
95+
if trackedRole.RoleID == roleID {
96+
hasRole = true
97+
break
98+
}
99+
}
100+
if !hasRole {
101+
err := app.storage.AddTrackedRole(member.User.ID, roleID, roleName)
102+
if err != nil {
103+
zap.L().Error("failed to add tracked role", zap.Error(err))
104+
} else {
105+
zap.L().Info("started tracking role",
106+
zap.String("user", member.User.Username),
107+
zap.String("role", roleName))
108+
}
109+
}
110+
}
111+
}
112+
113+
// Remove from database if no longer has the role
114+
for _, trackedRole := range user.TrackedRoles {
115+
if !currentTrackedRoles[trackedRole.RoleID] {
116+
err := app.storage.RemoveTrackedRole(member.User.ID, trackedRole.RoleID)
117+
if err != nil {
118+
zap.L().Error("failed to remove tracked role", zap.Error(err))
119+
} else {
120+
zap.L().Info("stopped tracking role",
121+
zap.String("user", member.User.Username),
122+
zap.String("role", trackedRole.RoleName))
123+
}
124+
}
125+
}
126+
}
127+
59128
// nolint:gocyclo
60129
func (app *App) onReady(s *discordgo.Session, event *discordgo.Ready) {
61130
zap.L().Debug("connected to Discord gateway")
@@ -160,10 +229,42 @@ func (app *App) onJoin(s *discordgo.Session, event *discordgo.GuildMemberAdd) {
160229
ch, err := s.UserChannelCreate(event.Member.User.ID)
161230
if err != nil {
162231
zap.L().Error("failed to create user channel", zap.Error(err))
163-
return
232+
} else {
233+
_, err = app.discordClient.S.ChannelMessageSend(ch.ID, greeting)
234+
if err != nil {
235+
zap.L().Error("failed to send message", zap.Error(err))
236+
}
164237
}
165-
_, err = app.discordClient.S.ChannelMessageSend(ch.ID, greeting)
238+
239+
err = app.reapplyTrackedRoles(event.Member.User.ID, event.GuildID)
166240
if err != nil {
167-
zap.L().Error("failed to send message", zap.Error(err))
241+
zap.L().Error("failed to reapply tracked roles", zap.Error(err))
168242
}
169243
}
244+
245+
func (app *App) reapplyTrackedRoles(userID, guildID string) error {
246+
trackedRoles, err := app.storage.GetTrackedRoles(userID)
247+
if err != nil {
248+
return err
249+
}
250+
251+
if len(trackedRoles) == 0 {
252+
return nil
253+
}
254+
255+
for _, role := range trackedRoles {
256+
err = app.discordClient.S.GuildMemberRoleAdd(guildID, userID, role.RoleID)
257+
if err != nil {
258+
zap.L().Error("failed to re-add tracked role",
259+
zap.Error(err),
260+
zap.String("user", userID),
261+
zap.String("role", role.RoleName))
262+
} else {
263+
zap.L().Info("reapplied tracked role",
264+
zap.String("user", userID),
265+
zap.String("role", role.RoleName))
266+
}
267+
}
268+
269+
return nil
270+
}

0 commit comments

Comments
 (0)