Skip to content

Commit c93f275

Browse files
authored
Merge pull request #157 from julwrites/staging
Functionality updates
2 parents 1b60ad5 + eda17d5 commit c93f275

17 files changed

Lines changed: 626 additions & 199 deletions

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@
44

55
This Telegram bot hopes to make the Bible more accessible, and hopefully to give a timely answer to those looking for it.
66

7+
### Features
8+
* **Bible Passage**: Get any Bible passage by typing the reference (e.g., "John 3:16").
9+
* **Bible Search**: Search for words in the Bible using `/search` (e.g., `/search grace`).
10+
* **Bible Ask**: Ask questions about the Bible using `/ask` (e.g., `/ask Who is Moses?`).
11+
* **Devotionals**: Get daily reading material with `/devo`.
12+
* **TMS**: Get Topical Memory System verses with `/tms`.
13+
714
### Feedback
815
Star this repo if you found it useful. Use the github issue tracker to give
916
feedback on this repo.

pkg/app/api_client.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ func getAPIConfig() (string, string) {
6666

6767
// SubmitQuery sends the QueryRequest to the Bible API and unmarshals the response into result.
6868
// result should be a pointer to the expected response struct.
69-
func SubmitQuery(req QueryRequest, result interface{}, projectID string) error {
69+
func SubmitQuery(req QueryRequest, result interface{}) error {
7070
apiURL, apiKey := getAPIConfig()
7171
if apiURL == "" {
7272
return fmt.Errorf("BIBLE_API_URL environment variable is not set")

pkg/app/api_client_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ func TestSubmitQuery(t *testing.T) {
2020
Context: QueryContext{User: UserContext{Version: "NIV"}},
2121
}
2222
var resp VerseResponse
23-
err := SubmitQuery(req, &resp, "")
23+
err := SubmitQuery(req, &resp)
2424
if err != nil {
2525
t.Errorf("Unexpected error: %v", err)
2626
}
@@ -36,7 +36,7 @@ func TestSubmitQuery(t *testing.T) {
3636

3737
req := QueryRequest{}
3838
var resp VerseResponse
39-
err := SubmitQuery(req, &resp, "")
39+
err := SubmitQuery(req, &resp)
4040
if err == nil {
4141
t.Error("Expected error when BIBLE_API_URL is unset")
4242
}

pkg/app/ask.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@ import (
66
"strings"
77

88
"github.com/julwrites/BotPlatform/pkg/def"
9-
"github.com/julwrites/ScriptureBot/pkg/secrets"
109
"github.com/julwrites/ScriptureBot/pkg/utils"
1110
)
1211

1312
func GetBibleAsk(env def.SessionData) def.SessionData {
13+
return GetBibleAskWithContext(env, nil)
14+
}
15+
16+
func GetBibleAskWithContext(env def.SessionData, contextVerses []string) def.SessionData {
1417
if len(env.Msg.Message) > 0 {
1518
config := utils.DeserializeUserConfig(env.User.Config)
1619

@@ -22,12 +25,12 @@ func GetBibleAsk(env def.SessionData) def.SessionData {
2225
User: UserContext{
2326
Version: config.Version,
2427
},
28+
Verses: contextVerses,
2529
},
2630
}
2731

2832
var resp OQueryResponse
29-
projectID, _ := secrets.Get("GCLOUD_PROJECT_ID")
30-
err := SubmitQuery(req, &resp, projectID)
33+
err := SubmitQuery(req, &resp)
3134
if err != nil {
3235
log.Printf("Error asking bible: %v", err)
3336
env.Res.Message = "Sorry, I encountered an error processing your question."

pkg/app/bible_reference.go

Lines changed: 160 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@ var BibleBooks = map[string]string{
1515
"joshua": "Joshua", "josh": "Joshua", "jos": "Joshua",
1616
"judges": "Judges", "judg": "Judges", "jdg": "Judges",
1717
"ruth": "Ruth", "ru": "Ruth", "rth": "Ruth",
18-
"1 samuel": "1 Samuel", "1 sam": "1 Samuel", "1 sa": "1 Samuel", "1sam": "1 Samuel", "1sa": "1 Samuel", "i sam": "1 Samuel",
19-
"2 samuel": "2 Samuel", "2 sam": "2 Samuel", "2 sa": "2 Samuel", "2sam": "2 Samuel", "2sa": "2 Samuel", "ii sam": "2 Samuel",
20-
"1 kings": "1 Kings", "1 kgs": "1 Kings", "1 ki": "1 Kings", "1kings": "1 Kings", "i kgs": "1 Kings",
21-
"2 kings": "2 Kings", "2 kgs": "2 Kings", "2 ki": "2 Kings", "2kings": "2 Kings", "ii kgs": "2 Kings",
22-
"1 chronicles": "1 Chronicles", "1 chron": "1 Chronicles", "1 chr": "1 Chronicles", "1 ch": "1 Chronicles", "1chronicles": "1 Chronicles", "i chron": "1 Chronicles",
23-
"2 chronicles": "2 Chronicles", "2 chron": "2 Chronicles", "2 chr": "2 Chronicles", "2 ch": "2 Chronicles", "2chronicles": "2 Chronicles", "ii chron": "2 Chronicles",
18+
"1 samuel": "1 Samuel", "1 sam": "1 Samuel", "1 sa": "1 Samuel", "1sam": "1 Samuel", "1sa": "1 Samuel", "i sam": "1 Samuel", "i samuel": "1 Samuel", "1st samuel": "1 Samuel",
19+
"2 samuel": "2 Samuel", "2 sam": "2 Samuel", "2 sa": "2 Samuel", "2sam": "2 Samuel", "2sa": "2 Samuel", "ii sam": "2 Samuel", "ii samuel": "2 Samuel", "2nd samuel": "2 Samuel",
20+
"1 kings": "1 Kings", "1 kgs": "1 Kings", "1 ki": "1 Kings", "1kings": "1 Kings", "i kgs": "1 Kings", "i kings": "1 Kings", "1st kings": "1 Kings",
21+
"2 kings": "2 Kings", "2 kgs": "2 Kings", "2 ki": "2 Kings", "2kings": "2 Kings", "ii kgs": "2 Kings", "ii kings": "2 Kings", "2nd kings": "2 Kings",
22+
"1 chronicles": "1 Chronicles", "1 chron": "1 Chronicles", "1 chr": "1 Chronicles", "1 ch": "1 Chronicles", "1chronicles": "1 Chronicles", "i chron": "1 Chronicles", "i chronicles": "1 Chronicles", "1st chronicles": "1 Chronicles",
23+
"2 chronicles": "2 Chronicles", "2 chron": "2 Chronicles", "2 chr": "2 Chronicles", "2 ch": "2 Chronicles", "2chronicles": "2 Chronicles", "ii chron": "2 Chronicles", "ii chronicles": "2 Chronicles", "2nd chronicles": "2 Chronicles",
2424
"ezra": "Ezra", "ezr": "Ezra",
2525
"nehemiah": "Nehemiah", "neh": "Nehemiah", "ne": "Nehemiah",
2626
"esther": "Esther", "est": "Esther", "esth": "Esther",
@@ -54,25 +54,25 @@ var BibleBooks = map[string]string{
5454
"john": "John", "jn": "John", "jhn": "John", "joh": "John",
5555
"acts": "Acts", "ac": "Acts", "act": "Acts",
5656
"romans": "Romans", "rom": "Romans", "ro": "Romans", "rm": "Romans",
57-
"1 corinthians": "1 Corinthians", "1 cor": "1 Corinthians", "1 co": "1 Corinthians", "1cor": "1 Corinthians", "i cor": "1 Corinthians",
58-
"2 corinthians": "2 Corinthians", "2 cor": "2 Corinthians", "2 co": "2 Corinthians", "2cor": "2 Corinthians", "ii cor": "2 Corinthians",
57+
"1 corinthians": "1 Corinthians", "1 cor": "1 Corinthians", "1 co": "1 Corinthians", "1cor": "1 Corinthians", "i cor": "1 Corinthians", "i corinthians": "1 Corinthians", "1st corinthians": "1 Corinthians",
58+
"2 corinthians": "2 Corinthians", "2 cor": "2 Corinthians", "2 co": "2 Corinthians", "2cor": "2 Corinthians", "ii cor": "2 Corinthians", "ii corinthians": "2 Corinthians", "2nd corinthians": "2 Corinthians",
5959
"galatians": "Galatians", "gal": "Galatians", "ga": "Galatians",
6060
"ephesians": "Ephesians", "eph": "Ephesians", "ep": "Ephesians",
6161
"philippians": "Philippians", "phil": "Philippians", "php": "Philippians",
6262
"colossians": "Colossians", "col": "Colossians",
63-
"1 thessalonians": "1 Thessalonians", "1 thess": "1 Thessalonians", "1 th": "1 Thessalonians", "1thess": "1 Thessalonians", "i thess": "1 Thessalonians",
64-
"2 thessalonians": "2 Thessalonians", "2 thess": "2 Thessalonians", "2 th": "2 Thessalonians", "2thess": "2 Thessalonians", "ii thess": "2 Thessalonians",
65-
"1 timothy": "1 Timothy", "1 tim": "1 Timothy", "1 ti": "1 Timothy", "1tim": "1 Timothy", "i tim": "1 Timothy",
66-
"2 timothy": "2 Timothy", "2 tim": "2 Timothy", "2 ti": "2 Timothy", "2tim": "2 Timothy", "ii tim": "2 Timothy",
63+
"1 thessalonians": "1 Thessalonians", "1 thess": "1 Thessalonians", "1 th": "1 Thessalonians", "1thess": "1 Thessalonians", "i thess": "1 Thessalonians", "i thessalonians": "1 Thessalonians", "1st thessalonians": "1 Thessalonians",
64+
"2 thessalonians": "2 Thessalonians", "2 thess": "2 Thessalonians", "2 th": "2 Thessalonians", "2thess": "2 Thessalonians", "ii thess": "2 Thessalonians", "ii thessalonians": "2 Thessalonians", "2nd thessalonians": "2 Thessalonians",
65+
"1 timothy": "1 Timothy", "1 tim": "1 Timothy", "1 ti": "1 Timothy", "1tim": "1 Timothy", "i tim": "1 Timothy", "i timothy": "1 Timothy", "1st timothy": "1 Timothy",
66+
"2 timothy": "2 Timothy", "2 tim": "2 Timothy", "2 ti": "2 Timothy", "2tim": "2 Timothy", "ii tim": "2 Timothy", "ii timothy": "2 Timothy", "2nd timothy": "2 Timothy",
6767
"titus": "Titus", "tit": "Titus", "ti": "Titus",
6868
"philemon": "Philemon", "philem": "Philemon", "phlm": "Philemon", "phm": "Philemon",
6969
"hebrews": "Hebrews", "heb": "Hebrews", "he": "Hebrews",
7070
"james": "James", "jas": "James", "jm": "James",
71-
"1 peter": "1 Peter", "1 pet": "1 Peter", "1 pe": "1 Peter", "1 pt": "1 Peter", "1peter": "1 Peter", "i pet": "1 Peter",
72-
"2 peter": "2 Peter", "2 pet": "2 Peter", "2 pe": "2 Peter", "2 pt": "2 Peter", "2peter": "2 Peter", "ii pet": "2 Peter",
73-
"1 john": "1 John", "1 jn": "1 John", "1jn": "1 John", "1john": "1 John", "i jn": "1 John",
74-
"2 john": "2 John", "2 jn": "2 John", "2jn": "2 John", "2john": "2 John", "ii jn": "2 John",
75-
"3 john": "3 John", "3 jn": "3 John", "3jn": "3 John", "3john": "3 John", "iii jn": "3 John",
71+
"1 peter": "1 Peter", "1 pet": "1 Peter", "1 pe": "1 Peter", "1 pt": "1 Peter", "1peter": "1 Peter", "i pet": "1 Peter", "i peter": "1 Peter", "1st peter": "1 Peter",
72+
"2 peter": "2 Peter", "2 pet": "2 Peter", "2 pe": "2 Peter", "2 pt": "2 Peter", "2peter": "2 Peter", "ii pet": "2 Peter", "ii peter": "2 Peter", "2nd peter": "2 Peter",
73+
"1 john": "1 John", "1 jn": "1 John", "1jn": "1 John", "1john": "1 John", "i jn": "1 John", "i john": "1 John", "1st john": "1 John",
74+
"2 john": "2 John", "2 jn": "2 John", "2jn": "2 John", "2john": "2 John", "ii jn": "2 John", "ii john": "2 John", "2nd john": "2 John",
75+
"3 john": "3 John", "3 jn": "3 John", "3jn": "3 John", "3john": "3 John", "iii jn": "3 John", "iii john": "3 John", "3rd john": "3 John",
7676
"jude": "Jude", "jud": "Jude", "jd": "Jude",
7777
"revelation": "Revelation", "rev": "Revelation", "re": "Revelation",
7878
}
@@ -110,63 +110,15 @@ func init() {
110110
// ParseBibleReference parses a string to identify and normalize a Bible reference.
111111
// It returns the normalized reference string and a boolean indicating validity.
112112
func ParseBibleReference(input string) (string, bool) {
113-
trimmedInput := strings.TrimSpace(input)
114-
if trimmedInput == "" {
113+
ref, consumedLen, ok := ParseBibleReferenceFromStart(input)
114+
if !ok {
115115
return "", false
116116
}
117-
lowerInput := strings.ToLower(trimmedInput)
118-
119-
var foundBook string
120-
var bookName string
121-
var remainder string
122-
123-
// 1. Try exact match (Greedy)
124-
for _, key := range sortedBookKeys {
125-
if strings.HasPrefix(lowerInput, key) {
126-
matchLen := len(key)
127-
if len(lowerInput) > matchLen {
128-
nextChar := lowerInput[matchLen]
129-
if isLetter(nextChar) {
130-
continue
131-
}
132-
}
133-
134-
foundBook = key
135-
bookName = BibleBooks[key]
136-
remainder = strings.TrimSpace(trimmedInput[matchLen:])
137-
break
138-
}
139-
}
140-
141-
// 2. If no exact match, try fuzzy matching
142-
if foundBook == "" {
143-
fBook, matchLen := findFuzzyMatch(trimmedInput) // Pass original case for splitting, but logic handles case
144-
if fBook != "" {
145-
bookName = fBook
146-
foundBook = fBook // Mark as found
147-
remainder = strings.TrimSpace(trimmedInput[matchLen:])
148-
}
149-
}
150-
151-
if foundBook == "" {
117+
// Verify that we consumed the entire string (ignoring whitespace)
118+
if len(strings.TrimSpace(input[consumedLen:])) > 0 {
152119
return "", false
153120
}
154-
155-
// Check remainder
156-
if remainder == "" {
157-
if SingleChapterBooks[bookName] {
158-
return bookName, true
159-
}
160-
// Multi-chapter book defaults to chapter 1
161-
return bookName + " 1", true
162-
}
163-
164-
// Remainder validation
165-
if isValidReferenceSyntax(remainder) {
166-
return bookName + " " + remainder, true
167-
}
168-
169-
return "", false
121+
return ref, true
170122
}
171123

172124
func findFuzzyMatch(input string) (string, int) {
@@ -246,19 +198,147 @@ func isLetter(c byte) bool {
246198
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
247199
}
248200

249-
func isValidReferenceSyntax(s string) bool {
250-
hasDigit := false
251-
for _, r := range s {
252-
switch {
253-
case r >= '0' && r <= '9':
254-
hasDigit = true
255-
case r == ' ' || r == ':' || r == '-' || r == '.' || r == ',' || r == '\t':
201+
// ExtractBibleReferences extracts all valid Bible references from a text.
202+
func ExtractBibleReferences(input string) []string {
203+
var refs []string
204+
205+
startIdx := 0
206+
length := len(input)
207+
208+
for startIdx < length {
209+
// If we are at a whitespace, advance.
210+
if input[startIdx] == ' ' || input[startIdx] == '\t' || input[startIdx] == '\n' || input[startIdx] == '\r' {
211+
startIdx++
212+
continue
213+
}
214+
215+
ref, consumed, ok := ParseBibleReferenceFromStart(input[startIdx:])
216+
if ok {
217+
refs = append(refs, ref)
218+
startIdx += consumed
219+
} else {
220+
// Advance to next word
221+
nextSpace := strings.IndexAny(input[startIdx:], " \t\n\r")
222+
if nextSpace == -1 {
223+
break
224+
}
225+
startIdx += nextSpace
226+
}
227+
}
228+
return refs
229+
}
230+
231+
// ParseBibleReferenceFromStart attempts to parse a Bible reference at the beginning of the string.
232+
// Returns the normalized reference, the length of text consumed from input, and whether a match was found.
233+
func ParseBibleReferenceFromStart(input string) (string, int, bool) {
234+
// 1. Skip leading whitespace
235+
startOffset := 0
236+
for startOffset < len(input) && (input[startOffset] == ' ' || input[startOffset] == '\t' || input[startOffset] == '\n' || input[startOffset] == '\r') {
237+
startOffset++
238+
}
239+
if startOffset == len(input) {
240+
return "", 0, false
241+
}
242+
243+
currentInput := input[startOffset:]
244+
lowerInput := strings.ToLower(currentInput)
245+
246+
var foundBook string
247+
var bookName string
248+
var matchLen int // Length in currentInput
249+
250+
// 1. Try exact match (Greedy)
251+
for _, key := range sortedBookKeys {
252+
if strings.HasPrefix(lowerInput, key) {
253+
mLen := len(key)
254+
// Ensure whole word match
255+
if len(lowerInput) > mLen {
256+
nextChar := lowerInput[mLen]
257+
if isLetter(nextChar) {
258+
continue
259+
}
260+
}
261+
262+
foundBook = key
263+
bookName = BibleBooks[key]
264+
matchLen = mLen
265+
break
266+
}
267+
}
268+
269+
// 2. If no exact match, try fuzzy matching
270+
if foundBook == "" {
271+
fBook, mLen := findFuzzyMatch(currentInput)
272+
if fBook != "" {
273+
bookName = fBook
274+
foundBook = fBook
275+
matchLen = mLen
276+
}
277+
}
278+
279+
if foundBook == "" {
280+
return "", 0, false
281+
}
282+
283+
// We found a book. Now parse the numbers (remainder).
284+
remainderStart := matchLen
285+
// Skip spaces after book
286+
for remainderStart < len(currentInput) && (currentInput[remainderStart] == ' ' || currentInput[remainderStart] == '\t') {
287+
remainderStart++
288+
}
289+
290+
remainder := currentInput[remainderStart:]
291+
292+
// Consume valid reference syntax
293+
syntax, syntaxLen := consumeReferenceSyntax(remainder)
294+
295+
totalConsumed := startOffset + remainderStart + syntaxLen
296+
297+
if syntax == "" {
298+
if SingleChapterBooks[bookName] {
299+
return bookName, totalConsumed, true
300+
}
301+
// Multi-chapter book defaults to chapter 1
302+
return bookName + " 1", totalConsumed, true
303+
}
304+
305+
if hasDigit(syntax) {
306+
return bookName + " " + syntax, totalConsumed, true
307+
}
308+
309+
if SingleChapterBooks[bookName] {
310+
return bookName, startOffset + matchLen, true // Don't consume the invalid syntax
311+
}
312+
return bookName + " 1", startOffset + matchLen, true
313+
}
314+
315+
func consumeReferenceSyntax(s string) (string, int) {
316+
lastDigit := -1
317+
318+
for i, r := range s {
319+
if r >= '0' && r <= '9' {
320+
lastDigit = i
321+
} else if r == ':' || r == '-' || r == '.' || r == ',' || r == ' ' || r == '\t' {
256322
continue
257-
default:
258-
return false // Invalid character
323+
} else {
324+
break
325+
}
326+
}
327+
328+
if lastDigit == -1 {
329+
return "", 0
330+
}
331+
332+
return s[:lastDigit+1], lastDigit+1
333+
}
334+
335+
func hasDigit(s string) bool {
336+
for _, r := range s {
337+
if r >= '0' && r <= '9' {
338+
return true
259339
}
260340
}
261-
return hasDigit
341+
return false
262342
}
263343

264344
func levenshteinDistance(s1, s2 string) int {

pkg/app/bible_reference_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,31 @@ func TestParseBibleReference(t *testing.T) {
6666
}
6767
}
6868
}
69+
70+
func TestExtractBibleReferences(t *testing.T) {
71+
tests := []struct {
72+
input string
73+
expected []string
74+
}{
75+
{"Read John 3:16", []string{"John 3:16"}},
76+
{"Compare Gen 1:1 and Ex 20", []string{"Genesis 1:1", "Exodus 20"}},
77+
{"What does it say in Mark 5?", []string{"Mark 5"}},
78+
{"I like Genesis", []string{"Genesis 1"}}, // Defaults to 1? Yes, per current logic.
79+
{"No references here", nil},
80+
{"John said hello", []string{"John 1"}}, // False positive risk, but per logic.
81+
{"Read 1 John 3 and 2 John", []string{"1 John 3", "2 John"}},
82+
}
83+
84+
for _, tt := range tests {
85+
result := ExtractBibleReferences(tt.input)
86+
if len(result) != len(tt.expected) {
87+
t.Errorf("ExtractBibleReferences(%q) length = %d, want %d", tt.input, len(result), len(tt.expected))
88+
continue
89+
}
90+
for i, ref := range result {
91+
if ref != tt.expected[i] {
92+
t.Errorf("ExtractBibleReferences(%q)[%d] = %q, want %q", tt.input, i, ref, tt.expected[i])
93+
}
94+
}
95+
}
96+
}

0 commit comments

Comments
 (0)