diff --git a/commands/debug.go b/commands/debug.go index fc0cd57..552cb7a 100644 --- a/commands/debug.go +++ b/commands/debug.go @@ -17,7 +17,7 @@ func (d DebugCommand) Name() string { } func (d DebugCommand) Help() string { - return "Show debug info (admin only)" + return "Show debug info" } func (d DebugCommand) Execute(update tgbotapi.Update, bot *tgbotapi.BotAPI) { diff --git a/commands/medchem.go b/commands/medchem.go new file mode 100644 index 0000000..b0eb3d0 --- /dev/null +++ b/commands/medchem.go @@ -0,0 +1,609 @@ +package commands + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strconv" + "strings" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +type MedchemCommand struct{} + +func (m MedchemCommand) Name() string { + return "medchem" +} + +func (m MedchemCommand) Help() string { + return "Get comprehensive medicinal chemistry properties for compounds. Usage: /medchem " +} + +// PubChem JSON structures +type PubChemCompound struct { + ID struct { + ID struct { + CID int `json:"cid"` + } `json:"id"` + } `json:"id"` + Props []struct { + URN struct { + Label string `json:"label"` + Name string `json:"name,omitempty"` + DataType int `json:"datatype"` + Version string `json:"version,omitempty"` + Software string `json:"software,omitempty"` + Source string `json:"source,omitempty"` + Release string `json:"release,omitempty"` + } `json:"urn"` + Value struct { + IVal int `json:"ival,omitempty"` + FVal float64 `json:"fval,omitempty"` + SVal string `json:"sval,omitempty"` + Binary string `json:"binary,omitempty"` + } `json:"value"` + } `json:"props"` + Atoms struct { + AID []int `json:"aid"` + Element []int `json:"element"` + } `json:"atoms,omitempty"` + Bonds struct { + AID1 []int `json:"aid1"` + AID2 []int `json:"aid2"` + Order []int `json:"order"` + } `json:"bonds,omitempty"` + Count struct { + HeavyAtom int `json:"heavy_atom"` + } `json:"count,omitempty"` +} + +type PubChemResponse struct { + PCCompounds []PubChemCompound `json:"PC_Compounds"` +} + +type PubChemSearchResponse struct { + IdentifierList struct { + CID []int `json:"CID"` + } `json:"IdentifierList"` +} + +type CompoundData struct { + CID int + Name string + IUPACName string + MolecularFormula string + MolecularWeight float64 + ExactMass float64 + XLogP float64 + TPSA float64 + Complexity float64 + HBondDonors int + HBondAcceptors int + RotatableBonds int + InChI string + InChIKey string + CanonicalSMILES string + HeavyAtomCount int + TotalAtomCount int + BondCount int +} + +type PropertyCategory int + +const ( + CategoryBasic PropertyCategory = iota + CategoryADME + CategoryStructure + CategoryIdentifiers +) + +func (m MedchemCommand) Execute(update tgbotapi.Update, bot *tgbotapi.BotAPI) { + query := strings.TrimSpace(update.Message.CommandArguments()) + if query == "" { + msg := tgbotapi.NewMessage(update.Message.Chat.ID, "๐Ÿงช *Medchem Command*\n\nUsage: `/medchem `\n\nExample: `/medchem aspirin`\n\nThis command provides comprehensive medicinal chemistry properties including ADME parameters, structural information, and molecular identifiers.") + msg.ParseMode = "Markdown" + bot.Send(msg) + return + } + + // Send "typing" action + typingAction := tgbotapi.NewChatAction(update.Message.Chat.ID, tgbotapi.ChatTyping) + bot.Send(typingAction) + + compound, err := fetchCompoundData(query) + if err != nil { + handleError(bot, update.Message.Chat.ID, err, query) + return + } + + sendCompoundInfo(bot, update.Message.Chat.ID, compound, CategoryBasic) +} + +func fetchCompoundData(query string) (*CompoundData, error) { + // First, search for compound to get CID + cid, err := searchCompoundCID(query) + if err != nil { + return nil, fmt.Errorf("compound not found: %v", err) + } + + // Get full compound record + url := fmt.Sprintf("https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/%d/record/JSON", cid) + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("network error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("PubChem API error: status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %v", err) + } + + var pubchemResp PubChemResponse + if err := json.Unmarshal(body, &pubchemResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %v", err) + } + + if len(pubchemResp.PCCompounds) == 0 { + return nil, fmt.Errorf("no compound data found") + } + + compound := parsePubChemData(&pubchemResp.PCCompounds[0], query) + return compound, nil +} + +func searchCompoundCID(query string) (int, error) { + url := fmt.Sprintf("https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/%s/cids/JSON", strings.ReplaceAll(query, " ", "%20")) + resp, err := http.Get(url) + if err != nil { + return 0, err + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + return 0, fmt.Errorf("compound '%s' not found", query) + } + + if resp.StatusCode != 200 { + return 0, fmt.Errorf("search failed with status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return 0, err + } + + var searchResp PubChemSearchResponse + if err := json.Unmarshal(body, &searchResp); err != nil { + return 0, err + } + + if len(searchResp.IdentifierList.CID) == 0 { + return 0, fmt.Errorf("no CID found for compound") + } + + return searchResp.IdentifierList.CID[0], nil +} + +func parsePubChemData(compound *PubChemCompound, originalName string) *CompoundData { + data := &CompoundData{ + CID: compound.ID.ID.CID, + Name: originalName, + } + + // Parse properties from the props array + for _, prop := range compound.Props { + label := prop.URN.Label + name := prop.URN.Name + + switch { + case label == "IUPAC Name" && name == "Preferred": + data.IUPACName = prop.Value.SVal + case label == "Molecular Formula": + data.MolecularFormula = prop.Value.SVal + case label == "Molecular Weight": + if weight, err := strconv.ParseFloat(prop.Value.SVal, 64); err == nil { + data.MolecularWeight = weight + } + case label == "Mass" && name == "Exact": + if mass, err := strconv.ParseFloat(prop.Value.SVal, 64); err == nil { + data.ExactMass = mass + } + case label == "Log P" && name == "XLogP3": + data.XLogP = prop.Value.FVal + case label == "Topological" && name == "Polar Surface Area": + data.TPSA = prop.Value.FVal + case label == "Compound Complexity": + data.Complexity = prop.Value.FVal + case label == "Count" && name == "Hydrogen Bond Donor": + data.HBondDonors = prop.Value.IVal + case label == "Count" && name == "Hydrogen Bond Acceptor": + data.HBondAcceptors = prop.Value.IVal + case label == "Count" && name == "Rotatable Bond": + data.RotatableBonds = prop.Value.IVal + case label == "InChI" && name == "Standard": + data.InChI = prop.Value.SVal + case label == "InChIKey" && name == "Standard": + data.InChIKey = prop.Value.SVal + case label == "SMILES" && name == "Canonical": + data.CanonicalSMILES = prop.Value.SVal + } + } + + // Get atom and bond counts + if compound.Count.HeavyAtom > 0 { + data.HeavyAtomCount = compound.Count.HeavyAtom + } + if len(compound.Atoms.AID) > 0 { + data.TotalAtomCount = len(compound.Atoms.AID) + } + if len(compound.Bonds.AID1) > 0 { + data.BondCount = len(compound.Bonds.AID1) + } + + return data +} + +func sendCompoundInfo(bot *tgbotapi.BotAPI, chatID int64, compound *CompoundData, category PropertyCategory) { + imageURL := fmt.Sprintf("https://pubchem.ncbi.nlm.nih.gov/image/imgsrv.fcgi?t=l&cid=%d", compound.CID) + caption := formatCompoundCaption(compound, category) + keyboard := createNavigationKeyboard(compound.CID, category) + + photo := tgbotapi.NewPhoto(chatID, tgbotapi.FileURL(imageURL)) + photo.Caption = caption + photo.ParseMode = "Markdown" + photo.ReplyMarkup = keyboard + bot.Send(photo) +} + +func formatCompoundCaption(compound *CompoundData, category PropertyCategory) string { + switch category { + case CategoryBasic: + return formatBasicInfo(compound) + case CategoryADME: + return formatADMEInfo(compound) + case CategoryStructure: + return formatStructureInfo(compound) + case CategoryIdentifiers: + return formatIdentifiersInfo(compound) + default: + return formatBasicInfo(compound) + } +} + +func formatBasicInfo(c *CompoundData) string { + b := &strings.Builder{} + fmt.Fprintf(b, "๐Ÿงช *%s*\n", c.Name) + fmt.Fprintf(b, "๐Ÿ“‹ *Basic Properties*\n\n") + + if c.IUPACName != "" { + fmt.Fprintf(b, "*IUPAC Name:* %s\n", c.IUPACName) + } + if c.MolecularFormula != "" { + fmt.Fprintf(b, "*Formula:* `%s`\n", c.MolecularFormula) + } + if c.MolecularWeight > 0 { + fmt.Fprintf(b, "*Molecular Weight:* %.2f g/mol\n", c.MolecularWeight) + } + if c.ExactMass > 0 { + fmt.Fprintf(b, "*Exact Mass:* %.6f\n", c.ExactMass) + } + + fmt.Fprintf(b, "\n๐Ÿ”— [PubChem](%s)", + fmt.Sprintf("https://pubchem.ncbi.nlm.nih.gov/compound/%d", c.CID)) + + return b.String() +} + +func formatADMEInfo(c *CompoundData) string { + b := &strings.Builder{} + fmt.Fprintf(b, "๐Ÿงช *%s*\n", c.Name) + fmt.Fprintf(b, "๐Ÿ’Š *ADME Properties*\n\n") + + if c.XLogP != 0 { + fmt.Fprintf(b, "*XLogP:* %.2f\n", c.XLogP) + fmt.Fprintf(b, "โ”œ Lipophilicity indicator\n") + } + if c.TPSA > 0 { + fmt.Fprintf(b, "*TPSA:* %.1f ลฒ\n", c.TPSA) + fmt.Fprintf(b, "โ”œ Topological Polar Surface Area\n") + } + if c.HBondDonors >= 0 { + fmt.Fprintf(b, "*H-bond Donors:* %d\n", c.HBondDonors) + } + if c.HBondAcceptors >= 0 { + fmt.Fprintf(b, "*H-bond Acceptors:* %d\n", c.HBondAcceptors) + } + if c.RotatableBonds >= 0 { + fmt.Fprintf(b, "*Rotatable Bonds:* %d\n", c.RotatableBonds) + fmt.Fprintf(b, "โ”œ Flexibility indicator\n") + } + + // Lipinski's Rule of Five analysis + fmt.Fprintf(b, "\n๐Ÿ“Š *Lipinski's Rule of Five:*\n") + violations := 0 + if c.MolecularWeight > 500 { + violations++ + fmt.Fprintf(b, "โŒ MW > 500\n") + } else { + fmt.Fprintf(b, "โœ… MW โ‰ค 500\n") + } + if c.XLogP > 5 { + violations++ + fmt.Fprintf(b, "โŒ XLogP > 5\n") + } else { + fmt.Fprintf(b, "โœ… XLogP โ‰ค 5\n") + } + if c.HBondDonors > 5 { + violations++ + fmt.Fprintf(b, "โŒ HBD > 5\n") + } else { + fmt.Fprintf(b, "โœ… HBD โ‰ค 5\n") + } + if c.HBondAcceptors > 10 { + violations++ + fmt.Fprintf(b, "โŒ HBA > 10\n") + } else { + fmt.Fprintf(b, "โœ… HBA โ‰ค 10\n") + } + + if violations == 0 { + fmt.Fprintf(b, "\n๐ŸŽฏ *Drug-like* (0 violations)") + } else { + fmt.Fprintf(b, "\nโš ๏ธ *%d violation(s)*", violations) + } + + return b.String() +} + +func formatStructureInfo(c *CompoundData) string { + b := &strings.Builder{} + fmt.Fprintf(b, "๐Ÿงช *%s*\n", c.Name) + fmt.Fprintf(b, "๐Ÿ—๏ธ *Structural Properties*\n\n") + + if c.Complexity > 0 { + fmt.Fprintf(b, "*Complexity:* %.0f\n", c.Complexity) + fmt.Fprintf(b, "โ”œ Structural complexity score\n") + } + if c.HeavyAtomCount > 0 { + fmt.Fprintf(b, "*Heavy Atoms:* %d\n", c.HeavyAtomCount) + } + if c.TotalAtomCount > 0 { + fmt.Fprintf(b, "*Total Atoms:* %d\n", c.TotalAtomCount) + } + if c.BondCount > 0 { + fmt.Fprintf(b, "*Bonds:* %d\n", c.BondCount) + } + if c.RotatableBonds >= 0 { + fmt.Fprintf(b, "*Rotatable Bonds:* %d\n", c.RotatableBonds) + } + + // Structural complexity assessment + if c.Complexity > 0 { + fmt.Fprintf(b, "\n๐Ÿ“ˆ *Complexity Assessment:*\n") + if c.Complexity < 100 { + fmt.Fprintf(b, "๐ŸŸข Simple structure") + } else if c.Complexity < 300 { + fmt.Fprintf(b, "๐ŸŸก Moderate complexity") + } else if c.Complexity < 500 { + fmt.Fprintf(b, "๐ŸŸ  Complex structure") + } else { + fmt.Fprintf(b, "๐Ÿ”ด Highly complex") + } + } + + return b.String() +} + +func formatIdentifiersInfo(c *CompoundData) string { + b := &strings.Builder{} + fmt.Fprintf(b, "๐Ÿงช *%s*\n", c.Name) + fmt.Fprintf(b, "๐Ÿท๏ธ *Chemical Identifiers*\n\n") + + fmt.Fprintf(b, "*CID:* `%d`\n", c.CID) + + if c.InChIKey != "" { + fmt.Fprintf(b, "*InChIKey:*\n`%s`\n\n", c.InChIKey) + } + + if c.CanonicalSMILES != "" { + fmt.Fprintf(b, "*SMILES:*\n`%s`\n\n", c.CanonicalSMILES) + } + + if c.InChI != "" { + // Truncate InChI if too long + inchi := c.InChI + if len(inchi) > 200 { + inchi = inchi[:197] + "..." + } + fmt.Fprintf(b, "*InChI:*\n`%s`", inchi) + } + + return b.String() +} + +func createNavigationKeyboard(cid int, currentCategory PropertyCategory) tgbotapi.InlineKeyboardMarkup { + var buttons [][]tgbotapi.InlineKeyboardButton + + // Category buttons + categoryRow := []tgbotapi.InlineKeyboardButton{ + tgbotapi.NewInlineKeyboardButtonData(getButtonText("๐Ÿ“‹", currentCategory == CategoryBasic), fmt.Sprintf("medchem:%d:basic", cid)), + tgbotapi.NewInlineKeyboardButtonData(getButtonText("๐Ÿ’Š", currentCategory == CategoryADME), fmt.Sprintf("medchem:%d:adme", cid)), + } + buttons = append(buttons, categoryRow) + + categoryRow2 := []tgbotapi.InlineKeyboardButton{ + tgbotapi.NewInlineKeyboardButtonData(getButtonText("๐Ÿ—๏ธ", currentCategory == CategoryStructure), fmt.Sprintf("medchem:%d:structure", cid)), + tgbotapi.NewInlineKeyboardButtonData(getButtonText("๐Ÿท๏ธ", currentCategory == CategoryIdentifiers), fmt.Sprintf("medchem:%d:identifiers", cid)), + } + buttons = append(buttons, categoryRow2) + + return tgbotapi.NewInlineKeyboardMarkup(buttons...) +} + +func getButtonText(emoji string, isActive bool) string { + if isActive { + return emoji + " โ—" + } + return emoji +} + +func handleError(bot *tgbotapi.BotAPI, chatID int64, err error, query string) { + var message string + + if strings.Contains(err.Error(), "not found") { + // Try to get suggestions + suggestions := getSuggestions(query) + if len(suggestions) > 0 { + message = fmt.Sprintf("โŒ Compound '%s' not found.\n\n๐Ÿ’ก *Did you mean:*\n%s", + query, strings.Join(suggestions, "\n")) + } else { + message = fmt.Sprintf("โŒ Compound '%s' not found.\n\n๐Ÿ’ก *Try:*\nโ€ข Check spelling\nโ€ข Use common name or IUPAC name\nโ€ข Try synonyms (e.g., 'aspirin' instead of 'acetylsalicylic acid')", query) + } + } else if strings.Contains(err.Error(), "network") { + message = "๐ŸŒ Network error. Please try again later." + } else { + message = "โš ๏ธ An error occurred while fetching compound data. Please try again." + } + + msg := tgbotapi.NewMessage(chatID, message) + msg.ParseMode = "Markdown" + bot.Send(msg) +} + +func getSuggestions(query string) []string { + url := fmt.Sprintf("https://pubchem.ncbi.nlm.nih.gov/rest/autocomplete/compound/%s/json?limit=3", + strings.ReplaceAll(query, " ", "%20")) + + resp, err := http.Get(url) + if err != nil { + return nil + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil + } + + var data struct { + Dictionary struct { + Terms []string `json:"terms"` + } `json:"dictionary"` + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil + } + + if err := json.Unmarshal(body, &data); err != nil { + return nil + } + + var suggestions []string + for _, term := range data.Dictionary.Terms { + suggestions = append(suggestions, "โ€ข "+term) + } + + return suggestions +} + +// HandleCallback handles inline keyboard button presses for medchem +func (m MedchemCommand) HandleCallback(update tgbotapi.Update, bot *tgbotapi.BotAPI, params []string) { + if len(params) < 2 { + log.Printf("Invalid medchem callback params: %v", params) + return + } + + cidStr := params[0] + categoryStr := params[1] + + cid, err := strconv.Atoi(cidStr) + if err != nil { + log.Printf("Invalid CID in callback: %s", cidStr) + return + } + + var category PropertyCategory + switch categoryStr { + case "basic": + category = CategoryBasic + case "adme": + category = CategoryADME + case "structure": + category = CategoryStructure + case "identifiers": + category = CategoryIdentifiers + default: + log.Printf("Invalid category in callback: %s", categoryStr) + return + } + + // Get compound data by CID + compound, err := fetchCompoundDataByCID(cid) + if err != nil { + log.Printf("Error fetching compound data for CID %d: %v", cid, err) + // Send error message + callback := tgbotapi.NewCallback(update.CallbackQuery.ID, "Error loading compound data") + bot.Request(callback) + return + } + + // Edit the message caption and keyboard + caption := formatCompoundCaption(compound, category) + keyboard := createNavigationKeyboard(compound.CID, category) + + editCaption := tgbotapi.NewEditMessageCaption( + update.CallbackQuery.Message.Chat.ID, + update.CallbackQuery.Message.MessageID, + caption, + ) + editCaption.ParseMode = "Markdown" + editCaption.ReplyMarkup = &keyboard + + if _, err := bot.Send(editCaption); err != nil { + log.Printf("Error editing message caption: %v", err) + } +} + +// fetchCompoundDataByCID fetches compound data directly by CID +func fetchCompoundDataByCID(cid int) (*CompoundData, error) { + url := fmt.Sprintf("https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/%d/record/JSON", cid) + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("network error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("PubChem API error: status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %v", err) + } + + var pubchemResp PubChemResponse + if err := json.Unmarshal(body, &pubchemResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %v", err) + } + + if len(pubchemResp.PCCompounds) == 0 { + return nil, fmt.Errorf("no compound data found") + } + + compound := parsePubChemData(&pubchemResp.PCCompounds[0], fmt.Sprintf("CID %d", cid)) + return compound, nil +} + +func init() { + Register(MedchemCommand{}) +} diff --git a/main.go b/main.go index cbaa4d3..047d09a 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "log" "os" "os/signal" + "strings" "syscall" "nignoggobot/commands" @@ -31,6 +32,37 @@ func notifyShutdown(reason string) { bot.Send(msg) } +func handleCallbackQuery(update tgbotapi.Update, bot *tgbotapi.BotAPI) { + query := update.CallbackQuery + + // Answer the callback query to remove the loading state + callback := tgbotapi.NewCallback(query.ID, "") + bot.Request(callback) + + // Parse callback data format: "command:param1:param2:..." + parts := strings.Split(query.Data, ":") + if len(parts) < 2 { + log.Printf("Invalid callback data format: %s", query.Data) + return + } + + commandName := parts[0] + + switch commandName { + case "medchem": + // Get the medchem command and handle callback + if cmd, ok := commands.All()["medchem"]; ok { + if medchemCmd, ok := cmd.(interface { + HandleCallback(tgbotapi.Update, *tgbotapi.BotAPI, []string) + }); ok { + medchemCmd.HandleCallback(update, bot, parts[1:]) + } + } + default: + log.Printf("Unknown callback command: %s", commandName) + } +} + func main() { // Signal handling for SIGINT/SIGTERM sigs := make(chan os.Signal, 1) @@ -63,11 +95,18 @@ func main() { updates := bot.GetUpdatesChan(u) for update := range updates { + // Handle callback queries (inline keyboard button presses) + if update.CallbackQuery != nil { + log.Printf("Received callback query: %s from user %d", update.CallbackQuery.Data, update.CallbackQuery.From.ID) + handleCallbackQuery(update, bot) + continue + } + if update.Message == nil || !update.Message.IsCommand() { continue } - log.Printf("Received command: %s from chat %d (user %d)", update.Message.Command(), update.Message.Chat.ID, update.Message.From.ID) + log.Printf("Received command: %s with args %s from chat %d (user %d)", update.Message.Command(), update.Message.CommandArguments(), update.Message.Chat.ID, update.Message.From.ID) cmdName := update.Message.Command() isGroup := update.Message.Chat.IsGroup() || update.Message.Chat.IsSuperGroup() @@ -79,8 +118,8 @@ func main() { cmd.Execute(update, bot) } else { log.Printf("Unknown command: %s", cmdName) - msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Unknown command.") - bot.Send(msg) + // msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Unknown command.") + // bot.Send(msg) } } }