Compare commits

...

46 Commits

Author SHA1 Message Date
ekzyis cec8aaf274 Ignore known parents 2024-09-26 19:54:38 +02:00
ekzyis f3cd10cbb8 Only reply on successful parse 2024-09-26 19:49:27 +02:00
ekzyis d7645edea9 Parse algebraic notation as input 2024-09-26 18:37:32 +02:00
ekzyis bf481bc452 Use first line that starts with @chess to start game 2024-09-26 18:06:21 +02:00
ekzyis fe82dee3bb Fix space in algebraic notation 2024-09-26 18:05:13 +02:00
ekzyis ee4e1f0edf Improve error message wrapping 2024-09-26 11:20:52 +02:00
ekzyis 5729ae631a Fix Qex6 move 2024-09-26 11:20:36 +02:00
ekzyis 31d9ff02d5 Improve error message 2024-09-26 11:11:14 +02:00
ekzyis d1315b931b Fix Bh4+ move
target was calculated without considering any suffices like + so it became "4+" instead of "h4".
2024-09-26 11:05:08 +02:00
ekzyis 2dc7cbf213 Add fromX, fromY to queen move 2024-09-26 10:40:29 +02:00
ekzyis 0175ac077f Add queenside castle 2024-09-26 10:38:40 +02:00
ekzyis 7aa65f4ec0 Add kingside castle 2024-09-26 10:30:04 +02:00
ekzyis be247e1f46 Add test for pins 2024-09-26 09:50:28 +02:00
ekzyis 908cc1cfda Add checks 2024-09-26 09:50:07 +02:00
ekzyis aa072ca4aa Add pawn promotion 2024-09-26 07:32:57 +02:00
ekzyis d98c0c221d Remove TODO about path collision
This is handled in the move function of the pieces.
2024-09-26 07:11:45 +02:00
ekzyis bbcd0b7725 Add TODO about ambiguous queen moves 2024-09-26 07:10:58 +02:00
ekzyis 4a3315eaa0 Fix ambiguous knight moves 2024-09-26 07:05:51 +02:00
ekzyis bab667d0ed Fix ambiguous rook moves 2024-09-26 07:05:51 +02:00
ekzyis e6f9c529f8 Parse file and rank from move
I realized that ambiguous moves might require the file _and_ rank.

Also, ambiguous moves are not limited to capturing moves.
2024-09-26 06:25:41 +02:00
ekzyis 87856f694d Test bishop captures 2024-09-26 04:02:28 +02:00
ekzyis 6a60aee89a Add more tests about blocked paths 2024-09-26 03:50:36 +02:00
ekzyis cb92e66580 Opt-in to parallel tests
Tests now take 2 seconds to complete. They took 12 seconds before!
2024-09-26 02:51:37 +02:00
ekzyis 8f2dc00d9f Fix missing assertions for b.Parse and b.Move 2024-09-26 02:49:20 +02:00
ekzyis 6fefeb44f5 Test ambiguous pawn captures 2024-09-26 02:39:53 +02:00
ekzyis a9a4216d4e Fix ambiguous knight capture 2024-09-26 02:27:51 +02:00
ekzyis 8f70fcb552 Return captureFrom as column index 2024-09-26 01:52:52 +02:00
ekzyis fddbd145f3 Use inline move function for early returns 2024-09-26 01:08:11 +02:00
ekzyis d6ffd8a57d Add easter egg error message 2024-09-26 01:01:08 +02:00
ekzyis 4d7ab3484d Remove TODO about parsing captures 2024-09-26 00:50:12 +02:00
ekzyis e12946ca5d Implement king moves 2024-09-26 00:49:24 +02:00
ekzyis 45c0dfce6f Test more invalid moves 2024-09-26 00:38:23 +02:00
ekzyis d3966685db Check for collisions with own pieces on target 2024-09-26 00:17:35 +02:00
ekzyis 4e12b40478 Fix pawn capture 2024-09-25 23:30:55 +02:00
ekzyis 516478d258 Reply with errors 2024-09-25 23:30:55 +02:00
ekzyis 6468955c23 Draw coordinates 2024-09-25 23:30:55 +02:00
ekzyis 2d99320942 Add TODO about rows and columns 2024-09-23 06:57:01 +02:00
ekzyis 11b2238c86 Support multiple initial moves 2024-09-23 06:52:58 +02:00
ekzyis 9a6579b6f8 Poll notifications every 15 seconds 2024-09-23 06:35:55 +02:00
ekzyis 2c6018755f Flip image each turn 2024-09-23 06:35:11 +02:00
ekzyis 72da5d3afd Include algebraic notation 2024-09-23 06:07:34 +02:00
ekzyis 677c50d007 Progress game via replies 2024-09-23 05:42:17 +02:00
ekzyis 7eea9d932f Upload image of board on mention 2024-09-23 04:22:02 +02:00
ekzyis e6b7582ae9 Rename to chessbot 2024-09-23 00:46:00 +02:00
ekzyis 5a52d48ef8 Implement pawn diagonal attack 2024-09-23 00:46:00 +02:00
ekzyis da103ef887 Better TODOs 2024-09-23 00:46:00 +02:00
12 changed files with 1916 additions and 193 deletions

3
.env.sample Normal file
View File

@ -0,0 +1,3 @@
SN_BASE_URL=
SN_API_TOKEN=
SN_MEDIA_URL=

2
.gitignore vendored
View File

@ -1,2 +1,4 @@
*.png
!assets/*.png
.env
*.sqlite3

File diff suppressed because it is too large Load Diff

View File

@ -4,9 +4,10 @@ import (
"fmt"
"os"
"path"
"strings"
"testing"
"github.com/ekzyis/sn-chess/chess"
"github.com/ekzyis/chessbot/chess"
"github.com/stretchr/testify/assert"
)
@ -18,6 +19,8 @@ func init() {
}
func TestBoardInitial(t *testing.T) {
t.Parallel()
b := chess.NewBoard()
assertPiece(t, b, "a1", chess.Rook, chess.Light)
@ -58,20 +61,24 @@ func TestBoardInitial(t *testing.T) {
}
func TestBoardMovePawn(t *testing.T) {
t.Parallel()
b := chess.NewBoard()
b.Move("e4")
assertParse(t, b, "e4")
assertNoPiece(t, b, "e2")
assertPiece(t, b, "e4", chess.Pawn, chess.Light)
b.Move("e5")
assertParse(t, b, "e5")
assertNoPiece(t, b, "e7")
assertPiece(t, b, "e5", chess.Pawn, chess.Dark)
}
func TestBoardMovePawnInvalid(t *testing.T) {
t.Parallel()
b := chess.NewBoard()
assertMoveError(t, b, "a5", "no pawn found that can move to a5")
@ -83,7 +90,7 @@ func TestBoardMovePawnInvalid(t *testing.T) {
assertMoveError(t, b, "g5", "no pawn found that can move to g5")
assertMoveError(t, b, "h5", "no pawn found that can move to h5")
b.Move("d4")
assertParse(t, b, "d4")
assertMoveError(t, b, "a4", "no pawn found that can move to a4")
assertMoveError(t, b, "b4", "no pawn found that can move to b4")
@ -95,83 +102,437 @@ func TestBoardMovePawnInvalid(t *testing.T) {
assertMoveError(t, b, "h4", "no pawn found that can move to h4")
}
func TestBoardMoveKnight(t *testing.T) {
func TestBoardMovePawnCapture(t *testing.T) {
t.Parallel()
b := chess.NewBoard()
b.Move("Nf3")
assertParse(t, b, "e4 d5 exd5")
assertNoPiece(t, b, "e4")
assertPiece(t, b, "d5", chess.Pawn, chess.Light)
// test ambiguous capture
b = chess.NewBoard()
assertParse(t, b, "c4 d5 e4 e5 exd5")
assertNoPiece(t, b, "e4")
assertPiece(t, b, "d5", chess.Pawn, chess.Light)
assertPiece(t, b, "c4", chess.Pawn, chess.Light)
b = chess.NewBoard()
assertParse(t, b, "c4 d5 e4 e5 cxd5")
assertNoPiece(t, b, "c4")
assertPiece(t, b, "d5", chess.Pawn, chess.Light)
assertPiece(t, b, "e4", chess.Pawn, chess.Light)
}
func TestBoardPawnPromotion(t *testing.T) {
t.Parallel()
b := chess.NewBoard()
b.Parse("a4 e6 a5 e5 a6 e4 axb7 e3 bxa8=Q")
assertPiece(t, b, "a8", chess.Queen, chess.Light)
assertNoPiece(t, b, "b7")
b = chess.NewBoard()
b.Parse("a4 e6 a5 e5 a6 e4 axb7 e3 bxa8=R")
assertPiece(t, b, "a8", chess.Rook, chess.Light)
assertNoPiece(t, b, "b7")
b = chess.NewBoard()
b.Parse("a4 e6 a5 e5 a6 e4 axb7 e3 bxa8=B")
assertPiece(t, b, "a8", chess.Bishop, chess.Light)
assertNoPiece(t, b, "b7")
b = chess.NewBoard()
b.Parse("a4 e6 a5 e5 a6 e4 axb7 e3 bxa8=N")
assertPiece(t, b, "a8", chess.Knight, chess.Light)
assertNoPiece(t, b, "b7")
b = chess.NewBoard()
b.Parse("a4 e6 a5 e5 a6 e4 axb7 e3")
assertMoveError(t, b, "bxa8=K", "invalid promotion: K")
}
func TestBoardMoveKnight(t *testing.T) {
t.Parallel()
b := chess.NewBoard()
assertParse(t, b, "Nf3")
assertPiece(t, b, "f3", chess.Knight, chess.Light)
assertNoPiece(t, b, "g1")
b.Move("Nh6")
assertParse(t, b, "Nh6")
assertPiece(t, b, "h6", chess.Knight, chess.Dark)
assertNoPiece(t, b, "g8")
assertParse(t, b, "Nc3")
assertPiece(t, b, "c3", chess.Knight, chess.Light)
assertNoPiece(t, b, "b1")
assertParse(t, b, "Na6")
assertPiece(t, b, "a6", chess.Knight, chess.Dark)
assertNoPiece(t, b, "b8")
assertParse(t, b, "Nh4")
assertPiece(t, b, "h4", chess.Knight, chess.Light)
assertNoPiece(t, b, "f3")
assertParse(t, b, "Nf5")
assertPiece(t, b, "f5", chess.Knight, chess.Dark)
assertNoPiece(t, b, "h6")
assertParse(t, b, "Na4")
assertPiece(t, b, "a4", chess.Knight, chess.Light)
assertNoPiece(t, b, "c3")
assertParse(t, b, "Nc5")
assertPiece(t, b, "c5", chess.Knight, chess.Dark)
assertNoPiece(t, b, "a6")
}
func TestBoardMoveKnightInvalid(t *testing.T) {
t.Parallel()
b := chess.NewBoard()
// out of reach
assertMoveError(t, b, "Ng3", "no knight found that can move to g3")
assertMoveError(t, b, "Nb3", "no knight found that can move to b3")
// blocked by own piece
assertMoveError(t, b, "Ng1", "g1 blocked by white knight")
assertMoveError(t, b, "Nd2", "d2 blocked by white pawn")
assertMoveError(t, b, "Ne2", "e2 blocked by white pawn")
assertParse(t, b, "Nf3")
assertMoveError(t, b, "Ng6", "no knight found that can move to g6")
assertMoveError(t, b, "Nb6", "no knight found that can move to b6")
assertMoveError(t, b, "Ne7", "e7 blocked by black pawn")
assertMoveError(t, b, "Nd7", "d7 blocked by black pawn")
// ambiguous moves
b = chess.NewBoard()
assertParse(t, b, "e4 e5 Nf3 d6 Nc3 d5 Nb5 d4")
assertMoveError(t, b, "Nxd4", "move ambiguous: 2 knights can move to d4")
assertMoveError(t, b, "N4xd4", "no knight found that can move to d4")
// disambiguate via file
assertParse(t, b, "Nfxd4")
b = chess.NewBoard()
assertParse(t, b, "e4 e5 Nf3 d6 Nc3 d5 Nb5 d4")
// disambiguate via rank
assertParse(t, b, "N3xd4")
}
func TestBoardMoveKnightCapture(t *testing.T) {
t.Parallel()
b := chess.NewBoard()
assertParse(t, b, "e4 Nf6 d4 Nxe4")
assertPiece(t, b, "e4", chess.Knight, chess.Dark)
assertPiece(t, b, "d4", chess.Pawn, chess.Light)
assertNoPiece(t, b, "g8")
assertNoPiece(t, b, "e2")
assertNoPiece(t, b, "d2")
// test ambiguous capture
b = chess.NewBoard()
assertParse(t, b, "e4 e5 Nf3 d6 Nc3 d5 Nb5 d4 Nbxd4")
assertPiece(t, b, "e4", chess.Pawn, chess.Light)
assertPiece(t, b, "e5", chess.Pawn, chess.Dark)
assertPiece(t, b, "d4", chess.Knight, chess.Light)
assertPiece(t, b, "f3", chess.Knight, chess.Light)
assertNoPiece(t, b, "g1")
assertNoPiece(t, b, "b1")
assertNoPiece(t, b, "c3")
assertNoPiece(t, b, "d6")
assertNoPiece(t, b, "d5")
assertNoPiece(t, b, "b5")
b = chess.NewBoard()
assertParse(t, b, "e4 e5 Nf3 d6 Nc3 d5 Nb5 d4 Nfxd4")
assertPiece(t, b, "e4", chess.Pawn, chess.Light)
assertPiece(t, b, "e5", chess.Pawn, chess.Dark)
assertPiece(t, b, "d4", chess.Knight, chess.Light)
assertPiece(t, b, "b5", chess.Knight, chess.Light)
assertNoPiece(t, b, "g1")
assertNoPiece(t, b, "b1")
assertNoPiece(t, b, "c3")
assertNoPiece(t, b, "d6")
assertNoPiece(t, b, "d5")
assertNoPiece(t, b, "f3")
}
func TestBoardMoveBishop(t *testing.T) {
b := chess.NewBoard()
t.Parallel()
b.Move("Bc4")
b := chess.NewBoard()
assertMoveError(t, b, "Bc4", "no bishop found that can move to c4")
b.Move("e3")
b.Move("e6")
assertParse(t, b, "e3")
assertParse(t, b, "e6")
b.Move("Bc4")
assertParse(t, b, "Bc4")
assertPiece(t, b, "c4", chess.Bishop, chess.Light)
assertNoPiece(t, b, "f1")
b.Move("Bc5")
assertParse(t, b, "Bc5")
assertPiece(t, b, "c5", chess.Bishop, chess.Dark)
assertNoPiece(t, b, "f8")
}
func TestBoardMoveRook(t *testing.T) {
func TestBoardMoveBishopInvalid(t *testing.T) {
t.Parallel()
b := chess.NewBoard()
b.Move("Ra3")
assertMoveError(t, b, "Bc3", "no bishop found that can move to c3")
assertMoveError(t, b, "Bc2", "c2 blocked by white pawn")
assertMoveError(t, b, "Bb2", "b2 blocked by white pawn")
// path blocked by white pawn at e2
assertMoveError(t, b, "Bd3", "no bishop found that can move to d3")
}
func TestBoardMoveBishopCapture(t *testing.T) {
t.Parallel()
b := chess.NewBoard()
assertParse(t, b, "e4 e5 Bc4 d5 Bxd5")
assertPiece(t, b, "e4", chess.Pawn, chess.Light)
assertPiece(t, b, "e5", chess.Pawn, chess.Dark)
assertPiece(t, b, "d5", chess.Bishop, chess.Light)
assertNoPiece(t, b, "f1")
assertNoPiece(t, b, "e2")
assertNoPiece(t, b, "e7")
assertNoPiece(t, b, "d7")
// bishop captures are never ambiguous because they can only move on same color
}
func TestBoardMoveRook(t *testing.T) {
t.Parallel()
b := chess.NewBoard()
assertMoveError(t, b, "Ra3", "no rook found that can move to a3")
b.Move("a4")
b.Move("a5")
b.Move("Ra3")
assertParse(t, b, "a4 a5 Ra3")
assertPiece(t, b, "a3", chess.Rook, chess.Light)
assertNoPiece(t, b, "a1")
b.Move("Ra6")
assertParse(t, b, "Ra6")
assertPiece(t, b, "a6", chess.Rook, chess.Dark)
assertNoPiece(t, b, "a8")
}
func TestBoardMoveQueen(t *testing.T) {
func TestBoardMoveRookCapture(t *testing.T) {
t.Parallel()
b := chess.NewBoard()
b.Move("Qd3")
assertParse(t, b, "a4 e6 h4 e5 Ra3 e4 Rhh3 e3 Raxe3")
assertPiece(t, b, "e3", chess.Rook, chess.Light)
assertPiece(t, b, "h3", chess.Rook, chess.Light)
assertNoPiece(t, b, "a3")
}
func TestBoardMoveRookInvalid(t *testing.T) {
t.Parallel()
b := chess.NewBoard()
assertMoveError(t, b, "Rb2", "b2 blocked by white pawn")
assertMoveError(t, b, "Rb1", "b1 blocked by white knight")
assertMoveError(t, b, "Ra2", "a2 blocked by white pawn")
assertMoveError(t, b, "Ra3", "no rook found that can move to a3")
assertParse(t, b, "e3 e6 a4 d6 Ra3")
// path blocked by pawn at d3
assertMoveError(t, b, "Rh3", "no rook found that can move to h3")
// ambiguous moves
b = chess.NewBoard()
assertParse(t, b, "a4 e6 h4 e5 Ra3 e4")
assertMoveError(t, b, "Rh3", "move ambiguous: 2 rooks can move to h3")
assertParse(t, b, "Rhh3")
}
func TestBoardMoveQueen(t *testing.T) {
t.Parallel()
b := chess.NewBoard()
assertMoveError(t, b, "Qd3", "no queen found that can move to d3")
b.Move("d4")
b.Move("d5")
assertParse(t, b, "d4")
assertParse(t, b, "d5")
b.Move("Qd3")
assertParse(t, b, "Qd3")
assertPiece(t, b, "d3", chess.Queen, chess.Light)
assertNoPiece(t, b, "d1")
b.Move("Qd6")
assertParse(t, b, "Qd6")
assertPiece(t, b, "d6", chess.Queen, chess.Dark)
assertNoPiece(t, b, "d8")
}
func TestBoardMoveQueenInvalid(t *testing.T) {
t.Parallel()
b := chess.NewBoard()
assertMoveError(t, b, "Qd2", "d2 blocked by white pawn")
assertMoveError(t, b, "Qd1", "d1 blocked by white queen")
assertMoveError(t, b, "Qe1", "e1 blocked by white king")
assertMoveError(t, b, "Qc1", "c1 blocked by white bishop")
// path blocked by white pawn at d2
assertMoveError(t, b, "Qd3", "no queen found that can move to d3")
// TODO: ambiguous queen moves require pawn promotion
}
func TestBoardMoveKing(t *testing.T) {
t.Parallel()
b := chess.NewBoard()
assertParse(t, b, "e4 e5 Ke2 Ke7 Kf3 Kd6 Kg3 Kc6")
assertNoPiece(t, b, "e1")
assertNoPiece(t, b, "e8")
assertNoPiece(t, b, "e2")
assertNoPiece(t, b, "e7")
assertNoPiece(t, b, "f3")
assertNoPiece(t, b, "d6")
assertPiece(t, b, "g3", chess.King, chess.Light)
assertPiece(t, b, "c6", chess.King, chess.Dark)
}
func TestBoardMoveKingInvalid(t *testing.T) {
t.Parallel()
b := chess.NewBoard()
assertMoveError(t, b, "Ke1", "e1 blocked by white king")
assertMoveError(t, b, "Ke2", "e2 blocked by white pawn")
assertMoveError(t, b, "Ke3", "no king found that can move to e3")
}
func TestBoardCheck(t *testing.T) {
t.Parallel()
b := chess.NewBoard()
assert.False(t, b.InCheck())
assertParse(t, b, "e4 e5 Qh5 Nc6 Qxf7")
assert.True(t, b.InCheck())
assert.True(t, strings.HasSuffix(b.Moves[len(b.Moves)-1], "+"), "check move should end with +")
assertMoveError(t, b, "Nf6", "invalid move Nf6: king is in check")
assertMoveError(t, b, "Ke7", "invalid move Ke7: king is in check")
assertParse(t, b, "Kxf7")
}
func TestBoardPin(t *testing.T) {
t.Parallel()
b := chess.NewBoard()
assertParse(t, b, "d4 e5 Nc3 Bb4")
assertMoveError(t, b, "Ne4", "invalid move Ne4: king is in check")
}
func TestBoardCastle(t *testing.T) {
t.Parallel()
b := chess.NewBoard()
assertParse(t, b, "e4 e5 Nf3 Nf6 Be2 Be7 O-O")
assertPiece(t, b, "f1", chess.Rook, chess.Light)
assertPiece(t, b, "g1", chess.King, chess.Light)
assertNoPiece(t, b, "h1")
assertNoPiece(t, b, "e1")
assertParse(t, b, "O-O")
assertPiece(t, b, "f8", chess.Rook, chess.Dark)
assertPiece(t, b, "g8", chess.King, chess.Dark)
assertNoPiece(t, b, "h8")
assertNoPiece(t, b, "e8")
b = chess.NewBoard()
assertParse(t, b, "e4 e5 Qg4 d6 d3 d5 Be3 d4 Nc3 dxe3 O-O-O")
assertPiece(t, b, "d1", chess.Rook, chess.Light)
assertPiece(t, b, "c1", chess.King, chess.Light)
assertNoPiece(t, b, "a1")
assertNoPiece(t, b, "e1")
}
func TestBoardParseAlgebraicNotation(t *testing.T) {
t.Parallel()
b := chess.NewBoard()
assertParse(t, b, "1.d4 d5 2.c4 e6")
assertPiece(t, b, "d4", chess.Pawn, chess.Light)
assertPiece(t, b, "d5", chess.Pawn, chess.Dark)
assertPiece(t, b, "c4", chess.Pawn, chess.Light)
assertPiece(t, b, "e6", chess.Pawn, chess.Dark)
assertNoPiece(t, b, "d2")
assertNoPiece(t, b, "d7")
assertNoPiece(t, b, "c2")
assertNoPiece(t, b, "e7")
}
func assertParse(t *testing.T, b *chess.Board, moves string) {
assert.NoError(t, b.Parse(moves))
}
func assertPiece(t *testing.T, b *chess.Board, position string, name chess.PieceName, color chess.Color) {
p := b.At(position)

19
chess/game_test.go Normal file
View File

@ -0,0 +1,19 @@
package chess_test
import (
"testing"
"github.com/ekzyis/chessbot/chess"
)
func TestGame001(t *testing.T) {
t.Parallel()
b := chess.NewBoard()
// this used to not parse because of the + at the end
assertParse(t, b, "d4 d5 Bf4 Nf6 e3 Ne4 Nc3 Nf2 Kxf2 e6 Qg4 Be7 Re1 O-O Kg3 Bh4+")
// this used to crash because of an out of bounds error (position was parsed as '6')
assertMoveError(t, b, "Qex6", "square does not exist")
}

View File

@ -16,6 +16,34 @@ type Piece struct {
Image image.Image
}
func (p *Piece) String() string {
n := ""
switch p.Name {
case Pawn:
n = "pawn"
case Knight:
n = "knight"
case Bishop:
n = "bishop"
case Rook:
n = "rook"
case Queen:
n = "queen"
case King:
n = "king"
}
c := ""
switch p.Color {
case Light:
c = "white"
case Dark:
c = "black"
}
return fmt.Sprintf("%s %s", c, n)
}
type PieceName string
const (

6
chess/square.go Normal file
View File

@ -0,0 +1,6 @@
package chess
type Square struct {
X int
Y int
}

108
db/db.go Normal file
View File

@ -0,0 +1,108 @@
package db
import (
"database/sql"
"errors"
"log"
sn "github.com/ekzyis/snappy"
_ "github.com/mattn/go-sqlite3"
)
var (
db = getDb()
)
func getDb() *sql.DB {
var (
db *sql.DB
err error
)
if db, err = sql.Open("sqlite3", "chessbot.sqlite3?_foreign_keys=on"); err != nil {
log.Fatal(err)
} else {
if err = migrate(db); err != nil {
log.Fatal(err)
}
}
return db
}
func migrate(db *sql.DB) error {
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
text TEXT NOT NULL,
parent_id INTEGER REFERENCES items(id),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`)
return err
}
func ItemHasReply(parentId int, userId int) (bool, error) {
var (
count int
err error
)
if err = db.QueryRow(`SELECT COUNT(1) FROM items WHERE parent_id = ? AND user_id = ?`, parentId, userId).Scan(&count); err != nil {
return true, err
}
if count > 0 {
return true, nil
}
// check if parent already exists, this means we ignored it
if err = db.QueryRow(`SELECT COUNT(1) FROM items WHERE id = ?`, parentId).Scan(&count); err != nil {
return true, err
}
if count > 0 {
log.Printf("ignoring known item %d", parentId)
}
return count > 0, nil
}
func InsertItem(item *sn.Item) error {
if _, err := db.Exec(``+
`INSERT INTO items(id, user_id, text, parent_id) VALUES (?, ?, ?, NULLIF(?, 0)) `+
`ON CONFLICT DO UPDATE SET text = EXCLUDED.text, updated_at = CURRENT_TIMESTAMP`,
item.Id, item.User.Id, item.Text, item.ParentId); err != nil {
return err
}
return nil
}
func GetThread(id int) ([]sn.Item, error) {
var (
items []sn.Item
err error
)
var item sn.Item
item.ParentId = id
for item.ParentId > 0 {
// TODO: can't select created_at because sqlite3 doesn't support timestamps natively
// see https://github.com/mattn/go-sqlite3/issues/142
if err = db.QueryRow(
`SELECT id, user_id, text, COALESCE(parent_id, 0) FROM items WHERE id = ?`, item.ParentId).
Scan(&item.Id, &item.User.Id, &item.Text, &item.ParentId); err != nil {
if err == sql.ErrNoRows {
return nil, errors.New("item not found in db")
}
return nil, err
}
items = append([]sn.Item{item}, items...)
}
return items, nil
}

5
go.mod
View File

@ -1,8 +1,10 @@
module github.com/ekzyis/sn-chess
module github.com/ekzyis/chessbot
go 1.23.0
require (
github.com/ekzyis/snappy v0.5.5-0.20240923014110-b880c1256e13
github.com/mattn/go-sqlite3 v1.14.23
github.com/stretchr/testify v1.9.0
golang.org/x/image v0.20.0
)
@ -10,5 +12,6 @@ require (
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/guregu/null.v4 v4.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

6
go.sum
View File

@ -1,5 +1,9 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ekzyis/snappy v0.5.5-0.20240923014110-b880c1256e13 h1:yxDqMo4HzCVzhxPNTBpST0KVVHVMwGouzwfXsb4WYw4=
github.com/ekzyis/snappy v0.5.5-0.20240923014110-b880c1256e13/go.mod h1:UksYI0dU0+cnzz0LQjWB1P0QQP/ghx47e4atP99a5Lk=
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
@ -8,5 +12,7 @@ golang.org/x/image v0.20.0 h1:7cVCUjQwfL18gyBJOmYvptfSHS8Fb3YUDtfLIZ7Nbpw=
golang.org/x/image v0.20.0/go.mod h1:0a88To4CYVBAHp5FXJm8o7QbUl37Vd85ply1vyD8auM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/guregu/null.v4 v4.0.0 h1:1Wm3S1WEA2I26Kq+6vcW+w0gcDo44YKYD7YIEJNHDjg=
gopkg.in/guregu/null.v4 v4.0.0/go.mod h1:YoQhUrADuG3i9WqesrCmpNRwm1ypAgSHYqoOcTu/JrI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

271
main.go
View File

@ -1,13 +1,278 @@
package main
import (
"github.com/ekzyis/sn-chess/chess"
"errors"
"fmt"
"log"
"math/rand"
"strings"
"time"
"github.com/ekzyis/chessbot/chess"
"github.com/ekzyis/chessbot/db"
"github.com/ekzyis/chessbot/sn"
)
var (
c = sn.GetClient()
// TODO: fetch our id from SN API
meId = 21858
)
func main() {
for {
tickGameStart(c)
tickGameProgress(c)
time.Sleep(15 * time.Second)
}
}
func tickGameStart(c *sn.Client) {
var (
b = chess.NewBoard()
mentions []sn.Notification
err error
)
b.Save("board.png")
if mentions, err = c.Mentions(); err != nil {
log.Printf("failed to fetch mentions: %v\n", err)
return
}
log.Printf("fetched %d mention(s)\n", len(mentions))
for _, n := range mentions {
if exists, err := db.ItemHasReply(n.Item.Id, meId); err != nil {
log.Printf("failed to check for existing reply to game start in item %d: %v\n", n.Item.Id, err)
continue
} else if exists {
// TODO: check if move changed
log.Printf("reply to game start in item %d already exists\n", n.Item.Id)
continue
}
if err = handleGameStart(&n.Item); err != nil {
// don't reply to mentions that we failed to parse as a game start
// to support unrelated mentions
if err.Error() == "failed to parse game start" {
log.Printf("ignoring error for item %d: %v\n", n.Item.Id, err)
return
}
if _, err2 := createComment(n.Item.Id, fmt.Sprintf("`%v`", err)); err2 != nil {
log.Printf("failed to reply with error to item %d: %v\n", n.Item.Id, err2)
} else {
log.Printf("replied to game start in item %d with error: %v\n", n.Item.Id, err)
}
} else {
log.Printf("started new game via item %d\n", n.Item.Id)
}
}
}
func tickGameProgress(c *sn.Client) {
var (
replies []sn.Notification
err error
)
if replies, err = c.Replies(); err != nil {
log.Printf("failed to fetch replies: %v\n", err)
return
}
log.Printf("fetched %d replies\n", len(replies))
for _, n := range replies {
if exists, err := db.ItemHasReply(n.Item.Id, meId); err != nil {
log.Printf("failed to check for existing reply to game update in item %d: %v\n", n.Item.Id, err)
continue
} else if exists {
// TODO: check if move changed
log.Printf("reply to game update in item %d already exists\n", n.Item.Id)
continue
}
if err = handleGameProgress(&n.Item); err != nil {
if err.Error() == "failed to parse game update" {
log.Printf("ignoring error for item %d: %v\n", n.Item.Id, err)
return
}
if _, err2 := createComment(n.Item.Id, fmt.Sprintf("`%v`", err)); err2 != nil {
log.Printf("failed to reply with error to item %d: %v\n", n.Item.Id, err2)
} else {
log.Printf("replied to game start in item %d with error: %v\n", n.Item.Id, err)
}
} else {
log.Printf("updated game via item %d\n", n.Item.Id)
}
}
}
func handleGameStart(req *sn.Item) error {
var (
move string
b *chess.Board
imgUrl string
res string
err error
)
// Immediately save game start request to db so we can store our reply to it in case of error.
// We set parentId to 0 such that parent_id will be NULL in the db and not hit foreign key constraints.
req.ParentId = 0
if err = db.InsertItem(req); err != nil {
return fmt.Errorf("failed to insert item %d into db: %v\n", req.Id, err)
}
if move, err = parseGameStart(req.Text); err != nil {
return err
}
// create board with initial move(s)
if b, err = chess.NewGame(move); err != nil {
if rand.Float32() > 0.99 {
// easter egg error message
return errors.New("Nice try, fed.")
}
return fmt.Errorf("failed to create new game from item %d: %v\n", req.Id, err)
}
// upload image of board
if imgUrl, err = c.UploadImage(b.Image()); err != nil {
return fmt.Errorf("failed to upload image for item %d: %v\n", req.Id, err)
}
// reply with algebraic notation and image
res = strings.Trim(fmt.Sprintf("%s\n\n%s", b.AlgebraicNotation(), imgUrl), " ")
if _, err = createComment(req.Id, res); err != nil {
return fmt.Errorf("failed to reply to item %d: %v\n", req.Id, err)
}
return nil
}
func handleGameProgress(req *sn.Item) error {
var (
thread []sn.Item
b = chess.NewBoard()
move string = strings.Trim(req.Text, " ")
imgUrl string
res string
err error
)
// immediately save game update request to db so we can store our reply to it in case of error
if err = db.InsertItem(req); err != nil {
return fmt.Errorf("failed to insert item %d into db: %v\n", req.Id, err)
}
// fetch thread to reconstruct all moves so far
if thread, err = db.GetThread(req.ParentId); err != nil {
return fmt.Errorf("failed to fetch thread for item %d: %v\n", req.ParentId, err)
}
for _, item := range thread {
if item.User.Id == meId {
continue
}
var moves string
if moves, err = parseGameProgress(item.Text); err != nil {
return err
}
// parse and execute existing moves
if err = b.Parse(moves); err != nil {
return err
}
}
// parse and execute new move
if move, err = parseGameProgress(move); err != nil {
return err
}
if err = b.Parse(move); err != nil {
if rand.Float32() > 0.99 {
// easter egg error message
return errors.New("Nice try, fed.")
}
return err
}
// upload image of updated board
if imgUrl, err = c.UploadImage(b.Image()); err != nil {
return fmt.Errorf("failed to upload image for item %d: %v\n", req.Id, err)
}
// reply with algebraic notation and image
res = strings.Trim(fmt.Sprintf("%s\n\n%s", b.AlgebraicNotation(), imgUrl), " ")
if _, err = createComment(req.Id, res); err != nil {
return fmt.Errorf("failed to reply to item %d: %v\n", req.Id, err)
}
return nil
}
func createComment(parentId int, text string) (*sn.Item, error) {
var (
commentId int
err error
)
if commentId, err = c.CreateComment(parentId, text); err != nil {
return nil, fmt.Errorf("failed to reply to item %d: %v\n", parentId, err)
}
var comment *sn.Item
if comment, err = c.Item(commentId); err != nil {
return nil, fmt.Errorf("failed to fetch item %d: %v\n", commentId, err)
}
if err = db.InsertItem(comment); err != nil {
return nil, fmt.Errorf("failed to insert item %d into db: %v\n", err, comment.Id)
}
return comment, nil
}
func parseGameStart(input string) (string, error) {
for _, line := range strings.Split(input, "\n") {
line = strings.Trim(line, " ")
if !strings.HasPrefix(line, "@chess") {
continue
}
return strings.Trim(strings.ReplaceAll(line, "@chess", ""), " "), nil
}
return "", errors.New("failed to parse game start")
}
func parseGameProgress(input string) (string, error) {
lines := strings.Split(input, "\n")
words := strings.Split(input, " ")
if len(lines) == 1 && len(words) == 1 {
return strings.Trim(input, " "), nil
}
for _, line := range strings.Split(input, "\n") {
if !strings.Contains(line, "@chess") {
continue
}
return strings.Trim(strings.ReplaceAll(line, "@chess", ""), " "), nil
}
return "", errors.New("failed to parse game update")
}

64
sn/sn.go Normal file
View File

@ -0,0 +1,64 @@
package sn
import (
"bufio"
"fmt"
"log"
"os"
"strings"
snappy "github.com/ekzyis/snappy"
)
type Client = snappy.Client
type Notification = snappy.Notification
type Item = snappy.Item
var (
c *Client
)
func GetClient() *Client {
loadEnv()
if c == nil {
c = snappy.NewClient(
snappy.WithBaseUrl(os.Getenv("SN_BASE_URL")),
snappy.WithApiKey(os.Getenv("SN_API_KEY")),
snappy.WithMediaUrl(os.Getenv("SN_MEDIA_URL")),
)
}
return c
}
func loadEnv() {
var (
f *os.File
s *bufio.Scanner
err error
)
if f, err = os.Open(".env"); err != nil {
log.Fatalf("error opening .env: %v", err)
}
defer f.Close()
s = bufio.NewScanner(f)
s.Split(bufio.ScanLines)
for s.Scan() {
line := s.Text()
parts := strings.SplitN(line, "=", 2)
// Check if we have exactly 2 parts (key and value)
if len(parts) == 2 {
os.Setenv(parts[0], parts[1])
} else {
log.Fatalf(".env: invalid line: %s\n", line)
}
}
// Check for errors during scanning
if err = s.Err(); err != nil {
fmt.Println("error scanning .env:", err)
}
}