├── project ├── build.properties └── plugins.sbt ├── core └── src │ ├── main │ ├── resources │ │ └── db │ │ │ └── migration │ │ │ ├── V4__unique_words.sql │ │ │ ├── V8__add_repost_chat_username_to_chats.sql │ │ │ ├── V7__subscriptions.sql │ │ │ ├── V6__add_updated_at_to_pairs.sql │ │ │ ├── V5__more_fks.sql │ │ │ ├── V3__foreign_keys.sql │ │ │ └── V2__init.sql │ └── scala │ │ └── com │ │ └── pepeground │ │ └── core │ │ ├── enums │ │ └── ChatType.scala │ │ ├── entities │ │ ├── WordEntity.scala │ │ ├── SubscriptionEntity.scala │ │ ├── ReplyEntity.scala │ │ ├── PairEntity.scala │ │ └── ChatEntity.scala │ │ ├── CoreConfig.scala │ │ ├── repositories │ │ ├── SubscriptionRepository.scala │ │ ├── LearnQueueRepository.scala │ │ ├── ContextRepository.scala │ │ ├── WordDeletionQueueRepository.scala │ │ ├── ReplyRepository.scala │ │ ├── WordRepository.scala │ │ ├── ChatRepository.scala │ │ └── PairRepository.scala │ │ ├── support │ │ └── PostgreSQLSyntaxSupport.scala │ │ └── services │ │ ├── LearnService.scala │ │ └── StoryService.scala │ └── test │ ├── resources │ ├── logback.xml │ └── application.conf.example │ └── scala │ └── com │ └── pepeground │ └── core │ ├── repositories │ ├── SubscriptionRepositoryTest.scala │ ├── WordRepositoryTest.scala │ ├── ChatRepositoryTest.scala │ ├── PairRepositoryTest.scala │ └── ReplyRepositoryTest.scala │ └── services │ ├── StoryServiceTest.scala │ └── LearnServiceTest.scala ├── Dockerfile ├── .gitignore ├── Makefile ├── .travis.yml ├── bot └── src │ └── main │ ├── scala │ └── com │ │ └── pepeground │ │ └── bot │ │ ├── handlers │ │ ├── PingHandler.scala │ │ ├── GetGabHandler.scala │ │ ├── GetRepostChatHandler.scala │ │ ├── CoolStoryHandler.scala │ │ ├── GetStatsHandler.scala │ │ ├── SetGabHandler.scala │ │ ├── RepostHandler.scala │ │ ├── SetRepostChatHandler.scala │ │ ├── MessageHandler.scala │ │ └── GenericHandler.scala │ │ ├── Config.scala │ │ ├── CleanUp.scala │ │ ├── Learn.scala │ │ ├── Main.scala │ │ ├── Prometheus.scala │ │ └── Router.scala │ └── resources │ ├── logback.xml │ ├── application.conf.example │ └── reference.conf ├── README.md ├── fabfile.py └── kubernetes └── deployment.yaml /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.5.5 2 | -------------------------------------------------------------------------------- /core/src/main/resources/db/migration/V4__unique_words.sql: -------------------------------------------------------------------------------- 1 | CREATE UNIQUE INDEX IF NOT EXISTS unique_word_word ON words USING btree(word); -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM openjdk:8-jdk 2 | 3 | ADD bot/target/scala-2.12/bot-assembly-0.1.jar /bot.jar 4 | 5 | CMD ["/usr/bin/java", "-jar", "-server", "/bot.jar"] 6 | -------------------------------------------------------------------------------- /core/src/main/resources/db/migration/V8__add_repost_chat_username_to_chats.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE chats ADD COLUMN IF NOT EXISTS repost_chat_username character varying; 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.9") 2 | addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.5.1") 3 | addCompilerPlugin( 4 | "org.scalamacros" % "paradise" % "2.1.1" cross CrossVersion.full 5 | ) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | target/ 3 | bot/src/main/resources/application.conf 4 | core/src/test/resources/application.conf 5 | core/src/test/resources/application.conf 6 | bot/target 7 | core/target 8 | project/project 9 | project/target 10 | -------------------------------------------------------------------------------- /core/src/main/resources/db/migration/V7__subscriptions.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS subscriptions( 2 | id SERIAL PRIMARY KEY NOT NULL, 3 | chat_id bigint NOT NULL, 4 | name character varying NOT NULL, 5 | since_id bigint 6 | ); -------------------------------------------------------------------------------- /core/src/main/resources/db/migration/V6__add_updated_at_to_pairs.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE pairs ADD COLUMN IF NOT EXISTS updated_at timestamp without time zone; 2 | UPDATE pairs SET updated_at = now() WHERE updated_at IS NULL; 3 | ALTER TABLE pairs ALTER COLUMN updated_at SET NOT NULL; -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: 2 | sbt bot/assembly 3 | docker build -t pepeground/pepeground-bot . 4 | docker push pepeground/pepeground-bot 5 | kubectl rollout restart -n pepeground deployment/pepeground-bot 6 | kubectl rollout restart -n pepeground deployment/pepeground-learn 7 | kubectl rollout restart -n pepeground deployment/pepeground-cleanup 8 | -------------------------------------------------------------------------------- /core/src/main/resources/db/migration/V5__more_fks.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE replies DROP CONSTRAINT IF EXISTS pair_id_fk; 2 | ALTER TABLE replies ADD CONSTRAINT pair_id_fk FOREIGN KEY (pair_id) REFERENCES pairs ON DELETE CASCADE; 3 | 4 | ALTER TABLE pairs DROP CONSTRAINT IF EXISTS chat_id_fk; 5 | ALTER TABLE pairs ADD CONSTRAINT chat_id_fk FOREIGN KEY (chat_id) REFERENCES chats ON DELETE CASCADE; -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: trusty 2 | sudo: false 3 | 4 | language: scala 5 | scala: 6 | - 2.12.2 7 | 8 | jdk: 9 | - oraclejdk8 10 | 11 | cache: 12 | directories: 13 | - $HOME/.ivy2 14 | - $HOME/.m2 15 | 16 | addons: 17 | postgresql: "9.6" 18 | 19 | before_script: 20 | - cp core/src/test/resources/application.conf.example core/src/test/resources/application.conf 21 | - psql -c 'create database pepeground_test;' -U postgres 22 | 23 | script: 24 | - sbt coverage core/test 25 | - sbt bot/assembly 26 | -------------------------------------------------------------------------------- /core/src/main/resources/db/migration/V3__foreign_keys.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE pairs DROP CONSTRAINT IF EXISTS first_id_fk; 2 | ALTER TABLE pairs ADD CONSTRAINT first_id_fk FOREIGN KEY (first_id) REFERENCES words ON DELETE CASCADE; 3 | ALTER TABLE pairs DROP CONSTRAINT IF EXISTS second_id_fk; 4 | ALTER TABLE pairs ADD CONSTRAINT second_id_fk FOREIGN KEY (second_id) REFERENCES words ON DELETE CASCADE; 5 | ALTER TABLE replies DROP CONSTRAINT IF EXISTS word_id_fk; 6 | ALTER TABLE replies ADD CONSTRAINT word_id_fk FOREIGN KEY (word_id) REFERENCES words ON DELETE CASCADE; -------------------------------------------------------------------------------- /core/src/test/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | %date %level [%thread] %logger{64} %msg%n 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /bot/src/main/scala/com/pepeground/bot/handlers/PingHandler.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.bot.handlers 2 | 3 | import com.bot4s.telegram.models.Message 4 | import scalikejdbc.DBSession 5 | 6 | object PingHandler { 7 | def apply(message: Message)(implicit session: DBSession): PingHandler = { 8 | new PingHandler(message) 9 | } 10 | } 11 | 12 | class PingHandler(message: Message)(implicit session: DBSession) extends GenericHandler(message) { 13 | def call(): Option[String] = { 14 | super.before() 15 | 16 | Some("Pong.") 17 | } 18 | } -------------------------------------------------------------------------------- /core/src/main/scala/com/pepeground/core/enums/ChatType.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.core.enums 2 | 3 | object ChatType { 4 | def apply(v: Int): String = { 5 | v match { 6 | case 0 => "chat" 7 | case 1 => "faction" 8 | case 2 => "supergroup" 9 | case 3 => "channel" 10 | case _ => "chat" 11 | } 12 | } 13 | 14 | def apply(v: String): Int = { 15 | v match { 16 | case "chat" => 0 17 | case "faction" => 1 18 | case "supergroup" => 2 19 | case "channel" => 3 20 | case _ => 0 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /core/src/test/resources/application.conf.example: -------------------------------------------------------------------------------- 1 | # Connection Pool settings 2 | db.default.poolInitialSize=5 3 | db.default.poolMaxSize=7 4 | db.default.poolConnectionTimeoutMillis=1000 5 | db.default.poolValidationQuery="select 1 as one" 6 | db.default.poolFactoryName="commons-dbcp2" 7 | 8 | # PostgreSQL example 9 | db.default.driver="org.postgresql.Driver" 10 | db.default.username="postgres" 11 | db.default.url="jdbc:postgresql://localhost:5432/pepeground_test" 12 | 13 | redis.host = "127.0.0.1" 14 | redis.port = 6379 15 | 16 | punctuation.endSentence = [".", "!", "?"] -------------------------------------------------------------------------------- /core/src/main/scala/com/pepeground/core/entities/WordEntity.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.core.entities 2 | 3 | import scalikejdbc._ 4 | 5 | case class WordEntity(id: Long, word: String) 6 | 7 | object WordEntity extends SQLSyntaxSupport[WordEntity] { 8 | override val tableName = "words" 9 | override val useSnakeCaseColumnName = true 10 | 11 | def apply(g: SyntaxProvider[WordEntity])(rs: WrappedResultSet): WordEntity = apply(g.resultName)(rs) 12 | def apply(c: ResultName[WordEntity])(rs: WrappedResultSet) = new WordEntity( 13 | rs.long(c.id), 14 | rs.string(c.word) 15 | ) 16 | } -------------------------------------------------------------------------------- /bot/src/main/scala/com/pepeground/bot/handlers/GetGabHandler.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.bot.handlers 2 | 3 | import com.bot4s.telegram.models.Message 4 | import scalikejdbc.DBSession 5 | 6 | object GetGabHandler { 7 | def apply(message: Message)(implicit session: DBSession): GetGabHandler = { 8 | new GetGabHandler(message) 9 | } 10 | } 11 | 12 | class GetGabHandler(message: Message)(implicit session: DBSession) extends GenericHandler(message) { 13 | def call(): Option[String] = { 14 | super.before() 15 | 16 | Some("Pizdlivost level is on %s".format(chat.randomChance)) 17 | } 18 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pepeground bot [![Build Status](https://travis-ci.org/pepeground/pepeground-bot.svg?branch=master)](https://travis-ci.org/pepeground/pepeground-bot) 2 | 3 | Scala implementation of [shizoid](https://github.com/top4ek/shizoid) 4 | 5 | ## Requirements 6 | 7 | * SBT 8 | * JDK 1.8 9 | * PostgreSQL 9.2 + 10 | * Redis Server 3.2.0 + 11 | 12 | ## Configuration 13 | 14 | ``` 15 | cp src/main/resources/application.conf.example /path/to/your/application.conf 16 | vim /path/to/your/application.conf 17 | ``` 18 | 19 | ## Running 20 | 21 | ```sh 22 | sbt assembly 23 | java -jar -Dconfig.file=/path/to/your/application.conf target/scala-2.12/bot-assembly-0.1.jar 24 | ``` 25 | -------------------------------------------------------------------------------- /core/src/main/scala/com/pepeground/core/CoreConfig.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.core 2 | 3 | import com.typesafe.config.ConfigFactory 4 | import scala.collection.JavaConverters._ 5 | 6 | object CoreConfig extends CoreConfig 7 | 8 | class CoreConfig { 9 | lazy val config = ConfigFactory.load() 10 | 11 | object redis { 12 | private lazy val redisConfig = config.getConfig("redis") 13 | lazy val host = redisConfig.getString("host") 14 | lazy val port = redisConfig.getInt("port") 15 | } 16 | 17 | object punctuation { 18 | private lazy val punctuationConfig = config.getConfig("punctuation") 19 | 20 | lazy val endSentence: List[String] = punctuationConfig.getStringList("endSentence").asScala.toList 21 | } 22 | } -------------------------------------------------------------------------------- /bot/src/main/scala/com/pepeground/bot/handlers/GetRepostChatHandler.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.bot.handlers 2 | 3 | import com.bot4s.telegram.models.Message 4 | import scalikejdbc.DBSession 5 | 6 | object GetRepostChatHandler { 7 | def apply(message: Message)(implicit session: DBSession): GetRepostChatHandler = { 8 | new GetRepostChatHandler(message) 9 | } 10 | } 11 | 12 | class GetRepostChatHandler(message: Message)(implicit session: DBSession) extends GenericHandler(message) { 13 | def call(): Option[String] = { 14 | super.before() 15 | 16 | chat.repostChatUsername match { 17 | case Some(username: String) => Some("Pidorskie quote is on %s".format(username)) 18 | case None => None 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /bot/src/main/scala/com/pepeground/bot/handlers/CoolStoryHandler.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.bot.handlers 2 | 3 | import com.pepeground.core.services.StoryService 4 | import com.bot4s.telegram.models.Message 5 | import scalikejdbc.DBSession 6 | 7 | object CoolStoryHandler { 8 | def apply(message: Message)(implicit session: DBSession): CoolStoryHandler = { 9 | new CoolStoryHandler(message) 10 | } 11 | } 12 | 13 | class CoolStoryHandler(message: Message)(implicit session: DBSession) extends GenericHandler(message) { 14 | private lazy val storyService: StoryService = new StoryService(List(), fullContext, chat.id, Some(50)) 15 | 16 | def call(): Option[String] = { 17 | super.before() 18 | 19 | storyService.generate() 20 | } 21 | } -------------------------------------------------------------------------------- /bot/src/main/scala/com/pepeground/bot/handlers/GetStatsHandler.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.bot.handlers 2 | 3 | import com.pepeground.core.repositories.PairRepository 4 | import com.bot4s.telegram.models.Message 5 | import scalikejdbc._ 6 | 7 | object GetStatsHandler { 8 | def apply(message: Message)(implicit session: DBSession): GetStatsHandler = { 9 | new GetStatsHandler(message) 10 | } 11 | } 12 | 13 | class GetStatsHandler(message: Message)(implicit session: DBSession) extends GenericHandler(message) { 14 | def call(): Option[String] = { 15 | super.before() 16 | 17 | val count: Int = DB readOnly { implicit session => PairRepository.getPairsCount(chat.id) } 18 | 19 | Some("Known pairs in this chat: %s.".format(count)) 20 | } 21 | } -------------------------------------------------------------------------------- /core/src/main/scala/com/pepeground/core/entities/SubscriptionEntity.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.core.entities 2 | 3 | import scalikejdbc._ 4 | 5 | case class SubscriptionEntity(id: Long, name: String, chatId: Long, sinceId: Option[Long]) 6 | 7 | object SubscriptionEntity extends SQLSyntaxSupport[SubscriptionEntity] { 8 | override val tableName = "subscriptions" 9 | override val useSnakeCaseColumnName = true 10 | 11 | def apply(g: SyntaxProvider[SubscriptionEntity])(rs: WrappedResultSet): SubscriptionEntity = apply(g.resultName)(rs) 12 | def apply(c: ResultName[SubscriptionEntity])(rs: WrappedResultSet) = new SubscriptionEntity( 13 | rs.long(c.id), 14 | rs.string(c.name), 15 | rs.long(c.chatId), 16 | rs.longOpt(c.sinceId) 17 | ) 18 | } -------------------------------------------------------------------------------- /bot/src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | %date %level [%thread] %logger{64} %msg%n 6 | 7 | 8 | 9 | 10 | 11 | 12 | WARN 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /bot/src/main/scala/com/pepeground/bot/handlers/SetGabHandler.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.bot.handlers 2 | 3 | import com.pepeground.core.repositories.ChatRepository 4 | import com.bot4s.telegram.models.Message 5 | import scalikejdbc.DBSession 6 | 7 | object SetGabHandler { 8 | def apply(message: Message)(implicit session: DBSession): SetGabHandler = { 9 | new SetGabHandler(message) 10 | } 11 | } 12 | 13 | class SetGabHandler(message: Message)(implicit session: DBSession) extends GenericHandler(message) { 14 | def call(level: Int): Option[String] = { 15 | super.before() 16 | 17 | if(level > 50 || level < 0) return Some("0-50 allowed, Dude!") 18 | ChatRepository.updateRandomChance(chat.id, level) 19 | 20 | Some(s"Ya wohl, Lord Helmet! Setting gab to ${level}") 21 | } 22 | } -------------------------------------------------------------------------------- /core/src/main/scala/com/pepeground/core/repositories/SubscriptionRepository.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.core.repositories 2 | 3 | import com.pepeground.core.entities.SubscriptionEntity 4 | import scalikejdbc._ 5 | 6 | object SubscriptionRepository { 7 | private val c = SubscriptionEntity.syntax("c") 8 | 9 | def getList()(implicit session: DBSession): List[SubscriptionEntity] = { 10 | withSQL { 11 | select.from(SubscriptionEntity as c) 12 | }.map(rs => SubscriptionEntity(c)(rs)).list().apply() 13 | } 14 | 15 | def updateSubscription(id: Long, sinceId: Long)(implicit session: DBSession): Unit = { 16 | withSQL { 17 | update(SubscriptionEntity).set( 18 | SubscriptionEntity.column.sinceId -> sinceId 19 | ).where.eq(SubscriptionEntity.column.id, id) 20 | }.update.apply() 21 | } 22 | } -------------------------------------------------------------------------------- /core/src/main/scala/com/pepeground/core/entities/ReplyEntity.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.core.entities 2 | 3 | import scalikejdbc._ 4 | 5 | case class ReplyEntity(id: Long, pairId: Long, wordId: Option[Long], count: Long) 6 | 7 | object ReplyEntity extends SQLSyntaxSupport[ReplyEntity] { 8 | override val tableName = "replies" 9 | override val useSnakeCaseColumnName = true 10 | 11 | def apply(g: SyntaxProvider[ReplyEntity])(rs: WrappedResultSet): ReplyEntity = apply(g.resultName)(rs) 12 | def apply(c: ResultName[ReplyEntity])(rs: WrappedResultSet) = new ReplyEntity( 13 | rs.long(c.id), 14 | rs.long(c.pairId), 15 | rs.longOpt(c.wordId), 16 | rs.long(c.count) 17 | ) 18 | 19 | def opt(m: SyntaxProvider[ReplyEntity])(rs: WrappedResultSet): Option[ReplyEntity] = 20 | rs.longOpt(m.resultName.id).map(_ => ReplyEntity(m)(rs)) 21 | } -------------------------------------------------------------------------------- /core/src/main/scala/com/pepeground/core/support/PostgreSQLSyntaxSupport.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.core.support 2 | 3 | import scalikejdbc._ 4 | 5 | object PostgreSQLSyntaxSupport { 6 | 7 | implicit class RichInsertSQLBuilder(val self: InsertSQLBuilder) extends AnyVal { 8 | def onConflictUpdate(constraint: String)(columnsAndValues: (SQLSyntax, Any)*): InsertSQLBuilder = { 9 | val cvs = columnsAndValues map { 10 | case (c, v) => sqls"$c = $v" 11 | } 12 | self.append(sqls"ON CONFLICT ON CONSTRAINT ${SQLSyntax.createUnsafely(constraint)} DO UPDATE SET ${sqls.csv(cvs: _*)}") 13 | } 14 | 15 | def onConflictDoNothing(): InsertSQLBuilder = { 16 | self.append(sqls"ON CONFLICT DO NOTHING") 17 | } 18 | } 19 | 20 | implicit class RichSQLSyntax(val self: sqls.type) extends AnyVal { 21 | def values(column: SQLSyntax): SQLSyntax = sqls"values($column)" 22 | } 23 | 24 | } -------------------------------------------------------------------------------- /bot/src/main/scala/com/pepeground/bot/Config.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.bot 2 | 3 | import com.pepeground.core.CoreConfig 4 | 5 | import scala.collection.JavaConverters._ 6 | 7 | object Config extends CoreConfig{ 8 | object bot { 9 | private lazy val botConfig = config.getConfig("bot") 10 | 11 | lazy val twitter = botConfig.getBoolean("twitter") 12 | 13 | lazy val asyncLear: Boolean = botConfig.getBoolean("asyncLearn") 14 | 15 | lazy val cleanupLimit: Int = botConfig.getLong("cleanupLimit").toInt 16 | lazy val repostChatIds: List[Long] = botConfig.getLongList("repostChatIds").asScala.toList.map(_.toLong) 17 | lazy val repostChatId: Long = botConfig.getLong("repostChatId") 18 | lazy val telegramToken: String = botConfig.getString("telegramToken") 19 | lazy val anchors: List[String] = botConfig.getStringList("anchors").asScala.toList 20 | lazy val name: String = botConfig.getString("name") 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /core/src/main/scala/com/pepeground/core/entities/PairEntity.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.core.entities 2 | 3 | import scalikejdbc._ 4 | import org.joda.time._ 5 | import scalikejdbc.jodatime.JodaTypeBinder._ 6 | 7 | case class PairEntity(id: Long, chatId: Long, firstId: Option[Long], secondId: Option[Long], createdAt: DateTime, 8 | replies: Seq[ReplyEntity] = Nil, updatedAt: DateTime) 9 | 10 | object PairEntity extends SQLSyntaxSupport[PairEntity] { 11 | override val tableName = "pairs" 12 | override val useSnakeCaseColumnName = true 13 | 14 | def apply(g: SyntaxProvider[PairEntity])(rs: WrappedResultSet): PairEntity = apply(g.resultName)(rs) 15 | def apply(c: ResultName[PairEntity])(rs: WrappedResultSet) = new PairEntity( 16 | rs.long(c.id), 17 | rs.long(c.chatId), 18 | rs.longOpt(c.firstId), 19 | rs.longOpt(c.secondId), 20 | rs.get(c.createdAt), 21 | updatedAt = rs.get(c.updatedAt) 22 | ) 23 | } -------------------------------------------------------------------------------- /bot/src/main/resources/application.conf.example: -------------------------------------------------------------------------------- 1 | # Connection Pool settings 2 | db.default.poolInitialSize=5 3 | db.default.poolMaxSize=7 4 | db.default.poolConnectionTimeoutMillis=1000 5 | db.default.poolValidationQuery="select 1 as one" 6 | db.default.poolFactoryName="commons-dbcp2" 7 | 8 | # PostgreSQL example 9 | db.default.driver="org.postgresql.Driver" 10 | db.default.url="jdbc:postgresql://localhost:5432/shizoid" 11 | 12 | redis.host = "127.0.0.1" 13 | redis.port = 6379 14 | 15 | punctuation.endSentence = [".", "!", "?"] 16 | 17 | bot.telegramToken = "" 18 | bot.name = "mrazota_bot" 19 | bot.anchors = [ 20 | "пепе", 21 | "pepe", 22 | "переграунд", 23 | "pepeground", 24 | "пепеграундес", 25 | "pepegroundes" 26 | ] 27 | 28 | akka { 29 | quartz { 30 | schedules { 31 | Cleanup { 32 | description = "A cron job that fires off every 30 seconds" 33 | expression = "0 */1 * ? * *" 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /bot/src/main/resources/reference.conf: -------------------------------------------------------------------------------- 1 | # Connection Pool settings 2 | db.default.poolInitialSize=5 3 | db.default.poolMaxSize=7 4 | db.default.poolConnectionTimeoutMillis=1000 5 | db.default.poolValidationQuery="select 1 as one" 6 | db.default.poolFactoryName="commons-dbcp2" 7 | 8 | redis.host = "127.0.0.1" 9 | redis.port = 6379 10 | 11 | punctuation.endSentence = [".", "!", "?"] 12 | 13 | bot.asyncLearn = false 14 | bot.cleanupLimit = 1000 15 | bot.twitter = false 16 | bot.repostChatIds = [] 17 | bot.repostChatId = 0 18 | bot.telegramToken = "" 19 | bot.name = "someBot" 20 | bot.anchors = [] 21 | 22 | akka { 23 | quartz { 24 | schedules { 25 | Cleanup { 26 | description = "A cron job that fires off every 30 seconds" 27 | expression = "0 */1 * ? * *" 28 | } 29 | Tweets { 30 | description = "A cron job that fires off every 30 seconds" 31 | expression = "0 */1 * ? * *" 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /bot/src/main/scala/com/pepeground/bot/CleanUp.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.bot 2 | 3 | import com.pepeground.core.repositories.PairRepository 4 | import com.typesafe.scalalogging.Logger 5 | import org.slf4j.LoggerFactory 6 | import scalikejdbc.DB 7 | 8 | object CleanUp { 9 | private val logger = Logger(LoggerFactory.getLogger(this.getClass)) 10 | 11 | def run = { 12 | while (true) { 13 | try { 14 | cleanUp 15 | } catch { 16 | case e: Throwable => 17 | logger.error(e.getMessage) 18 | Thread.sleep(5000) 19 | } 20 | } 21 | } 22 | private def cleanUp = { 23 | DB localTx { implicit session => 24 | val removedIds: List[Long] = PairRepository.removeOld(Config.bot.cleanupLimit) 25 | 26 | if (removedIds.isEmpty) { 27 | logger.info("NOTHING TO REMOVE") 28 | Thread.sleep(5000) 29 | } else { 30 | logger.info("REMOVED PAIRS %s (%s)".format(removedIds.size, removedIds.mkString(", "))) 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /core/src/main/scala/com/pepeground/core/entities/ChatEntity.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.core.entities 2 | 3 | import scalikejdbc._ 4 | import org.joda.time._ 5 | import scalikejdbc.jodatime.JodaTypeBinder._ 6 | 7 | case class ChatEntity(id: Long, name: Option[String], telegramId: Long, repostChatUsername: Option[String], 8 | chatType: Int, randomChance: Int, createdAt: DateTime, updatedAt: DateTime) 9 | 10 | object ChatEntity extends SQLSyntaxSupport[ChatEntity] { 11 | override val tableName = "chats" 12 | override val useSnakeCaseColumnName = true 13 | 14 | def apply(g: SyntaxProvider[ChatEntity])(rs: WrappedResultSet): ChatEntity = apply(g.resultName)(rs) 15 | def apply(c: ResultName[ChatEntity])(rs: WrappedResultSet) = new ChatEntity( 16 | rs.long(c.id), 17 | rs.stringOpt(c.name), 18 | rs.long(c.telegramId), 19 | rs.stringOpt(c.repostChatUsername), 20 | rs.int(c.chatType), 21 | rs.int(c.randomChance), 22 | rs.get(c.createdAt), 23 | rs.get(c.updatedAt) 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /bot/src/main/scala/com/pepeground/bot/Learn.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.bot 2 | 3 | import com.pepeground.core.repositories.LearnItem 4 | import com.pepeground.core.repositories.LearnQueueRepository 5 | import com.pepeground.core.services.LearnService 6 | import scalikejdbc.DB 7 | import com.typesafe.scalalogging.Logger 8 | import org.slf4j.LoggerFactory 9 | 10 | object Learn { 11 | private val logger = Logger(LoggerFactory.getLogger(this.getClass)) 12 | private lazy val learnQueueRepository = LearnQueueRepository 13 | 14 | def run = { 15 | while (true) { 16 | try { 17 | learn 18 | } catch { 19 | case e: Throwable => 20 | logger.error(e.getMessage) 21 | Thread.sleep(5000) 22 | } 23 | } 24 | } 25 | 26 | private def learn = { 27 | learnQueueRepository.pop() match { 28 | case Some(li: LearnItem) => DB localTx { implicit session => 29 | new LearnService(li.message, li.chatId).learnPair() 30 | } 31 | case None => Thread.sleep(100) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /core/src/main/scala/com/pepeground/core/repositories/LearnQueueRepository.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.core.repositories 2 | 3 | import com.pepeground.core.CoreConfig 4 | import com.redis._ 5 | 6 | import io.circe._ 7 | import io.circe.generic.auto._ 8 | import io.circe.parser._ 9 | import io.circe.syntax._ 10 | 11 | case class LearnItem(message: List[String], chatId: Long) 12 | 13 | object LearnQueueRepository { 14 | val clients = new RedisClientPool(CoreConfig.redis.host, CoreConfig.redis.port, database = 11) 15 | val learnQueue = "learn" 16 | 17 | def push(message: List[String], chatId: Long): Unit = { 18 | clients.withClient(cli => cli.lpush(key = learnQueue, value = LearnItem(message, chatId).asJson.noSpaces)) 19 | } 20 | 21 | def pop(): Option[LearnItem] = { 22 | clients.withClient { cli => 23 | val el = cli.rpop(learnQueue) 24 | 25 | el match { 26 | case Some(str: String) => decode[LearnItem](str) match { 27 | case Right(s: LearnItem) => Option(s) 28 | case Left(_: Error) => None 29 | } 30 | case None => None 31 | } 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /fabfile.py: -------------------------------------------------------------------------------- 1 | from fabric.api import env, run, sudo, local, put, settings 2 | from contextlib import contextmanager 3 | import os 4 | import fabtools 5 | 6 | env.hosts = ['pepeground'] 7 | env.shell = 'bash -c' 8 | env.use_ssh_config = True 9 | 10 | @contextmanager 11 | def locally(): 12 | global run 13 | global sudo 14 | global local 15 | _run, _sudo = run, sudo 16 | run = sudo = local 17 | yield 18 | run, sudo = _run, _sudo 19 | 20 | def assembly(): 21 | with locally(): 22 | run("sbt assembly") 23 | 24 | 25 | def production(): 26 | env.project_user = run("whoami") 27 | env.sudo_user = env.project_user 28 | env.base_dir = "/home/%s/pepeground" % (env.project_user) 29 | 30 | def setup(): 31 | run("mkdir -p %s" % env.base_dir) 32 | 33 | def upload(): 34 | put("bot/target/scala-2.12/bot-assembly-0.1.jar", "%s/bot.jar" % (env.base_dir)) 35 | 36 | def restart(): 37 | run("sudo systemctl restart pepeground") 38 | 39 | def deploy(): 40 | assembly() 41 | production() 42 | setup() 43 | upload() 44 | restart() 45 | -------------------------------------------------------------------------------- /core/src/main/scala/com/pepeground/core/repositories/ContextRepository.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.core.repositories 2 | 3 | import com.pepeground.core.CoreConfig 4 | import com.redis._ 5 | 6 | object ContextRepository { 7 | val clients = new RedisClientPool(CoreConfig.redis.host, CoreConfig.redis.port, database = 11) 8 | 9 | def updateContext(path: String, words: List[String]): Unit = { 10 | val ctx: List[String] = getContext(path, 50) 11 | 12 | clients.withClient { client => 13 | val filteredWords: List[String] = words.distinct.map(_.toLowerCase).filter(!ctx.contains(_)) 14 | val aggregatedWords: List[String] = (filteredWords ++ ctx).take(50) 15 | 16 | client.pipeline { p => 17 | p.del(path) 18 | p.lpush(path, aggregatedWords.headOption.getOrElse(""), aggregatedWords.tail: _*) 19 | } 20 | } 21 | } 22 | 23 | def getContext(path: String, limit: Int = 50): List[String] = { 24 | clients.withClient { client => 25 | client 26 | .lrange[String](path, 0, limit) 27 | .getOrElse(List()) 28 | .filter(_.nonEmpty) 29 | .map(_.get) 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /bot/src/main/scala/com/pepeground/bot/Main.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.bot 2 | 3 | import org.flywaydb.core.Flyway 4 | import scalikejdbc.ConnectionPool 5 | import scalikejdbc.config._ 6 | 7 | import scala.concurrent.Await 8 | import scala.concurrent.duration.Duration 9 | 10 | object Main extends App { 11 | override def main(args: Array[String]): Unit = { 12 | java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone("UTC")) 13 | 14 | DBs.setupAll() 15 | 16 | val flyway: Flyway = new Flyway() 17 | val dataSource = ConnectionPool.dataSource(ConnectionPool.DEFAULT_NAME) 18 | 19 | flyway.setDataSource(dataSource) 20 | flyway.baseline() 21 | flyway.migrate() 22 | 23 | args.headOption match { 24 | case Some("learn") => Learn.run 25 | case Some("cleanup") => CleanUp.run 26 | case Some("bot") => 27 | print("Running bot") 28 | Await.ready(Router.run(), Duration.Inf) 29 | case Some(x: String) => 30 | print(s"Unknown application argument: ${x}") 31 | System.exit(1) 32 | case None => 33 | print("Missing application argument") 34 | System.exit(1) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /core/src/main/scala/com/pepeground/core/repositories/WordDeletionQueueRepository.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.core.repositories 2 | 3 | import com.pepeground.core.CoreConfig 4 | import com.redis._ 5 | 6 | import io.circe._ 7 | import io.circe.generic.auto._ 8 | import io.circe.parser._ 9 | import io.circe.syntax._ 10 | 11 | case class WordForDeletion(wordId: Long, name: String) 12 | 13 | object WordDeletionQueueRepository { 14 | val clients = new RedisClientPool(CoreConfig.redis.host, CoreConfig.redis.port, database = 11) 15 | val wordDeletionQueue = "words_for_deletion" 16 | 17 | def push(wordId: Long, name: String): Unit = { 18 | clients.withClient(cli => cli.lpush(key = wordDeletionQueue, value = WordForDeletion(wordId, name).asJson.noSpaces)) 19 | } 20 | 21 | def pop(): Option[WordForDeletion] = { 22 | clients.withClient { cli => 23 | val el = cli.rpop(wordDeletionQueue) 24 | 25 | el match { 26 | case Some(str: String) => decode[WordForDeletion](str) match { 27 | case Right(s: WordForDeletion) => Option(s) 28 | case Left(_: Error) => None 29 | } 30 | case None => None 31 | } 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /bot/src/main/scala/com/pepeground/bot/handlers/RepostHandler.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.bot.handlers 2 | 3 | import com.pepeground.bot.Config 4 | import com.bot4s.telegram.methods.ForwardMessage 5 | import com.bot4s.telegram.models.{Message, User} 6 | import scalikejdbc.DBSession 7 | 8 | object RepostHandler { 9 | def apply(message: Message)(implicit session: DBSession): RepostHandler = { 10 | new RepostHandler(message) 11 | } 12 | } 13 | 14 | class RepostHandler(message: Message)(implicit session: DBSession) extends GenericHandler(message) { 15 | def call(): Option[ForwardMessage] = { 16 | super.before() 17 | 18 | if(canRepost) { 19 | Some( 20 | ForwardMessage( 21 | chat.repostChatUsername.get, 22 | message.chat.id, 23 | None, 24 | message.replyToMessage.get.messageId 25 | ) 26 | ) 27 | } else { 28 | None 29 | } 30 | } 31 | 32 | def canRepost: Boolean = { 33 | message.replyToMessage match { 34 | case None => false 35 | case Some(mo: Message) => mo.from match { 36 | case None => false 37 | case Some(u: User) => u.username match { 38 | case None => false 39 | case Some(username: String) => username.toLowerCase == Config.bot.name.toLowerCase 40 | } 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /bot/src/main/scala/com/pepeground/bot/Prometheus.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.bot 2 | 3 | import com.sun.net.httpserver.HttpExchange 4 | import io.micrometer.prometheus.{PrometheusConfig, PrometheusMeterRegistry} 5 | import com.sun.net.httpserver.HttpServer 6 | import java.io.IOException 7 | import java.net.InetSocketAddress 8 | 9 | object Prometheus { 10 | val prometheusRegistry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT) 11 | 12 | def start(): Unit = server.start() 13 | 14 | private lazy val server = buildServer() 15 | 16 | private def buildServer() = { 17 | try { 18 | val server = HttpServer.create(new InetSocketAddress(8080), 0) 19 | server.createContext("/metrics", (httpExchange: HttpExchange) => { 20 | def serve(httpExchange: HttpExchange) = { 21 | val response = prometheusRegistry.scrape() 22 | httpExchange.sendResponseHeaders(200, response.getBytes.length) 23 | try { 24 | val os = httpExchange.getResponseBody 25 | try 26 | os.write(response.getBytes) 27 | finally if (os != null) os.close() 28 | } 29 | } 30 | 31 | serve(httpExchange) 32 | }) 33 | new Thread { server.start() } 34 | } catch { 35 | case e: IOException => 36 | throw new RuntimeException(e) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /bot/src/main/scala/com/pepeground/bot/handlers/SetRepostChatHandler.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.bot.handlers 2 | 3 | import com.pepeground.core.repositories.ChatRepository 4 | import com.bot4s.telegram.models.{ChatMember, Message} 5 | import scalikejdbc.DBSession 6 | 7 | import scala.concurrent.{Await, Future} 8 | import scala.concurrent.duration._ 9 | 10 | object SetRepostChatHandler { 11 | def apply(message: Message, chatMemberRequest: Option[Future[ChatMember]])(implicit session: DBSession): SetRepostChatHandler = { 12 | new SetRepostChatHandler(message, chatMemberRequest) 13 | } 14 | } 15 | 16 | class SetRepostChatHandler(message: Message, chatMemberRequest: Option[Future[ChatMember]])(implicit session: DBSession) extends GenericHandler(message) { 17 | final val AdminStatuses = Array("creator", "administrator") 18 | 19 | def call(chatUsername: String): Option[String] = { 20 | super.before() 21 | 22 | if(canSetRepostChat) { 23 | ChatRepository.updateRepostChat(chat.id, chatUsername) 24 | Some(s"Ya wohl, Lord Helmet! Setting repost channel to ${chatUsername}") 25 | } else { 26 | None 27 | } 28 | } 29 | 30 | def canSetRepostChat: Boolean = { 31 | chatMemberRequest match { 32 | case None => false 33 | case Some(c) => { 34 | val status = Await.result(c, 1 minute).status 35 | AdminStatuses.contains(status) 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /core/src/test/scala/com/pepeground/core/repositories/SubscriptionRepositoryTest.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.core.repositories 2 | 3 | import org.flywaydb.core.Flyway 4 | import scalikejdbc.scalatest.AutoRollback 5 | import org.scalatest._ 6 | import org.scalatest.fixture.FlatSpec 7 | import scalikejdbc._ 8 | import scalikejdbc.config.DBs 9 | 10 | class SubscriptionRepositoryTest extends FlatSpec with BeforeAndAfter with AutoRollback { 11 | before { 12 | DBs.setupAll() 13 | 14 | val flyway: Flyway = new Flyway() 15 | val dataSource = ConnectionPool.dataSource(ConnectionPool.DEFAULT_NAME) 16 | 17 | flyway.setDataSource(dataSource) 18 | flyway.baseline() 19 | flyway.migrate() 20 | } 21 | 22 | override def fixture(implicit session: DBSession) { 23 | val chat = ChatRepository.create(1, "Some chat", "private") 24 | 25 | sql"insert into subscriptions values (1, ${chat.id}, ${"Alice"}, 0)".update.apply() 26 | sql"insert into subscriptions values (2, ${chat.id}, ${"Bob"}, 0)".update.apply() 27 | } 28 | 29 | behavior of "getList" 30 | 31 | it should "return list of subscriptions" in { implicit session => 32 | val subscriptions = SubscriptionRepository.getList() 33 | 34 | assert(subscriptions.size == 2) 35 | } 36 | 37 | behavior of "updateSubscription" 38 | 39 | it should "update subscription since_id" in { implicit session => 40 | val subscriptions = SubscriptionRepository.getList() 41 | 42 | subscriptions.foreach(s => SubscriptionRepository.updateSubscription(s.id, 10)) 43 | 44 | SubscriptionRepository.getList().foreach { s => 45 | assert(s.sinceId.nonEmpty) 46 | assert(s.sinceId.get == 10) 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /bot/src/main/scala/com/pepeground/bot/handlers/MessageHandler.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.bot.handlers 2 | 3 | import com.pepeground.core.repositories.{ContextRepository, LearnQueueRepository} 4 | import com.pepeground.core.services.{LearnService, StoryService} 5 | import com.bot4s.telegram.models.Message 6 | import com.pepeground.bot.Config 7 | import com.typesafe.scalalogging._ 8 | import org.slf4j.LoggerFactory 9 | import scalikejdbc.DBSession 10 | 11 | object MessageHandler { 12 | def apply(message: Message)(implicit session: DBSession): MessageHandler = { 13 | new MessageHandler(message) 14 | } 15 | } 16 | 17 | class MessageHandler(message: Message)(implicit session: DBSession) extends GenericHandler(message) { 18 | private val logger = Logger(LoggerFactory.getLogger(this.getClass)) 19 | private lazy val learnService: LearnService = new LearnService(words, chat.id) 20 | private lazy val storyService: StoryService = new StoryService(words, context, chat.id) 21 | private lazy val learnQueueRepository = LearnQueueRepository 22 | 23 | def call(): Option[Either[Option[String], Option[String]]] = { 24 | super.before() 25 | 26 | if (!hasText || isEdition) return None 27 | 28 | logger.info("Message received: %s from %s (%s)".format(message.text.getOrElse(""), chatName, migrationId)) 29 | 30 | learn() 31 | 32 | ContextRepository.updateContext(chatContext, words) 33 | 34 | if (isReplyToBot) return Option(Left(storyService.generate())) 35 | if (isMentioned) return Option(Left(storyService.generate())) 36 | if (isPrivate) return Option(Right(storyService.generate())) 37 | if (hasAnchors) return Option(Right(storyService.generate())) 38 | if (isRandomAnswer) return Option(Right(storyService.generate())) 39 | 40 | None 41 | } 42 | 43 | private def learn(): Unit = { 44 | if (Config.bot.asyncLear) { 45 | learnQueueRepository.push(words, chat.id) 46 | } else { 47 | learnService.learnPair() 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /core/src/main/resources/db/migration/V2__init.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS chats( 2 | id SERIAL PRIMARY KEY NOT NULL, 3 | telegram_id bigint NOT NULL, 4 | chat_type smallint NOT NULL, 5 | random_chance smallint DEFAULT 5 NOT NULL, 6 | created_at timestamp without time zone NOT NULL, 7 | updated_at timestamp without time zone NOT NULL, 8 | name character varying 9 | ); 10 | 11 | CREATE INDEX IF NOT EXISTS index_chats_on_telegram_id ON chats USING btree (telegram_id); 12 | 13 | CREATE TABLE IF NOT EXISTS pairs ( 14 | id SERIAL PRIMARY KEY NOT NULL, 15 | chat_id integer NOT NULL, 16 | first_id integer, 17 | second_id integer, 18 | created_at timestamp without time zone NOT NULL 19 | ); 20 | 21 | CREATE INDEX IF NOT EXISTS index_pairs_on_chat_id ON pairs USING btree (chat_id); 22 | CREATE INDEX IF NOT EXISTS index_pairs_on_first_id ON pairs USING btree (first_id); 23 | CREATE INDEX IF NOT EXISTS index_pairs_on_second_id ON pairs USING btree (second_id); 24 | CREATE UNIQUE INDEX IF NOT EXISTS unique_pair_chat_id_first_id ON pairs USING btree (chat_id, first_id) WHERE (second_id IS NULL); 25 | CREATE UNIQUE INDEX IF NOT EXISTS unique_pair_chat_id_first_id_second_id ON pairs USING btree (chat_id, first_id, second_id); 26 | CREATE UNIQUE INDEX IF NOT EXISTS unique_pair_chat_id_second_id ON pairs USING btree (chat_id, second_id) WHERE (first_id IS NULL); 27 | 28 | CREATE TABLE IF NOT EXISTS replies ( 29 | id SERIAL PRIMARY KEY NOT NULL, 30 | pair_id integer NOT NULL, 31 | word_id integer, 32 | count bigint DEFAULT 1 NOT NULL 33 | ); 34 | 35 | CREATE UNIQUE INDEX IF NOT EXISTS unique_reply_pair_id ON replies USING btree (pair_id) WHERE (word_id IS NULL); 36 | CREATE UNIQUE INDEX IF NOT EXISTS unique_reply_pair_id_word_id ON replies USING btree (pair_id, word_id); 37 | 38 | CREATE TABLE IF NOT EXISTS words ( 39 | id SERIAL PRIMARY KEY NOT NULL, 40 | word character varying NOT NULL 41 | ); 42 | 43 | CREATE INDEX IF NOT EXISTS index_words_on_word ON words USING btree (word); -------------------------------------------------------------------------------- /core/src/main/scala/com/pepeground/core/services/LearnService.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.core.services 2 | 3 | import scalikejdbc._ 4 | import com.pepeground.core.CoreConfig 5 | import com.pepeground.core.entities.{ReplyEntity, WordEntity} 6 | import com.pepeground.core.repositories.{PairRepository, ReplyRepository, WordRepository} 7 | 8 | import scala.collection.mutable.{Map => MMap} 9 | import scala.collection.mutable.ListBuffer 10 | 11 | class LearnService(words: List[String], chatId: Long)(implicit session: DBSession) { 12 | def learnPair(): Unit = { 13 | WordRepository.learnWords(words) 14 | var newWords: ListBuffer[Option[String]] = ListBuffer(None) 15 | val preloadedWords: Map[String, WordEntity] = WordRepository.getByWords(words).map(we => we.word -> we).toMap 16 | 17 | words.foreach { w => 18 | newWords += Option(w) 19 | 20 | if (CoreConfig.punctuation.endSentence.contains(w.takeRight(1))) newWords += None 21 | } 22 | 23 | newWords.last match { 24 | case Some(s: String) => newWords += None 25 | case _ => 26 | } 27 | 28 | var pairIds: ListBuffer[Long] = ListBuffer() 29 | 30 | while(newWords.nonEmpty) { 31 | val trigramMap: MMap[Int, Long] = MMap() 32 | val trigram = newWords.take(3) 33 | newWords.remove(0, 1) 34 | 35 | trigram.zipWithIndex.foreach { case (w,i) => 36 | w match { 37 | case Some(s: String) => preloadedWords.get(s) match { 38 | case Some(we: WordEntity) => trigramMap.put(i, we.id) 39 | case None => 40 | } 41 | case None => 42 | } 43 | } 44 | 45 | val pair = PairRepository.getPairOrCreateBy(chatId, trigramMap.get(0), trigramMap.get(1)) 46 | pairIds += pair.id 47 | 48 | ReplyRepository.getReplyBy(pair.id, trigramMap.get(2)) match { 49 | case Some(r: ReplyEntity) => ReplyRepository.incrementReply(r.id, r.count) 50 | case None => ReplyRepository.createReplyBy(pair.id, trigramMap.get(2)) 51 | } 52 | } 53 | 54 | PairRepository.touch(pairIds.toList) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /core/src/main/scala/com/pepeground/core/repositories/ReplyRepository.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.core.repositories 2 | 3 | import java.util.NoSuchElementException 4 | 5 | import com.pepeground.core.entities.ReplyEntity 6 | import scalikejdbc._ 7 | import com.pepeground.core.support.PostgreSQLSyntaxSupport._ 8 | 9 | object ReplyRepository { 10 | private val r = ReplyEntity.syntax("r") 11 | 12 | def hasWithWordId(wordId: Long)(implicit session: DBSession): Boolean = { 13 | withSQL { 14 | select(r.id).from(ReplyEntity as r).where.eq(r.wordId, wordId).limit(1) 15 | }.map(rs => rs.long("id")).single.apply().isDefined 16 | } 17 | 18 | def repliesForPair(pairId: Long)(implicit session: DBSession): List[ReplyEntity] = { 19 | withSQL { 20 | select 21 | .from(ReplyEntity as r) 22 | .where.eq(r.pairId, pairId) 23 | .orderBy(r.count.desc) 24 | .limit(3) 25 | }.map(rs => ReplyEntity(r)(rs)).list().apply() 26 | } 27 | 28 | def getReplyOrCreateBy(pairId: Long, wordId: Option[Long])(implicit session: DBSession): ReplyEntity = { 29 | getReplyBy(pairId, wordId) match { 30 | case Some(reply: ReplyEntity) => reply 31 | case None => createReplyBy(pairId, wordId) 32 | } 33 | } 34 | 35 | def incrementReply(id: Long, counter: Long)(implicit session: DBSession): Unit = { 36 | withSQL { 37 | update(ReplyEntity).set( 38 | ReplyEntity.column.count -> (counter + 1) 39 | ).where.eq(ReplyEntity.column.id, id) 40 | }.update.apply() 41 | } 42 | 43 | def getReplyBy(pairId: Long, wordId: Option[Long])(implicit session: DBSession): Option[ReplyEntity] = { 44 | withSQL { 45 | select.from(ReplyEntity as r).where.eq(r.wordId, wordId).and.eq(r.pairId, pairId).limit(1) 46 | }.map(rs => ReplyEntity(r)(rs)).single.apply() 47 | } 48 | 49 | def createReplyBy(pairId: Long, wordId: Option[Long])(implicit session: DBSession): ReplyEntity = { 50 | withSQL { 51 | insert.into(ReplyEntity).namedValues( 52 | ReplyEntity.column.pairId -> pairId, 53 | ReplyEntity.column.wordId -> wordId 54 | ).onConflictDoNothing() 55 | }.update().apply() 56 | 57 | getReplyBy(pairId, wordId) match { 58 | case Some(reply: ReplyEntity) => reply 59 | case None => throw new NoSuchElementException("No such reply") 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /core/src/test/scala/com/pepeground/core/services/StoryServiceTest.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.core.services 2 | 3 | import com.pepeground.core.repositories.{ChatRepository, PairRepository, ReplyRepository, WordRepository} 4 | import org.flywaydb.core.Flyway 5 | import org.joda.time.DateTime 6 | import scalikejdbc.scalatest.AutoRollback 7 | import org.scalatest._ 8 | import org.scalatest.fixture.FlatSpec 9 | import scalikejdbc._ 10 | import scalikejdbc.config.DBs 11 | 12 | import scala.io.Source 13 | 14 | class StoryServiceTest extends FlatSpec with BeforeAndAfter with AutoRollback { 15 | before { 16 | DBs.setupAll() 17 | 18 | val flyway: Flyway = new Flyway() 19 | val dataSource = ConnectionPool.dataSource(ConnectionPool.DEFAULT_NAME) 20 | 21 | flyway.setDataSource(dataSource) 22 | flyway.baseline() 23 | flyway.migrate() 24 | } 25 | 26 | behavior of "generate" 27 | 28 | it should "generate empty on new dictionary" in { implicit session => 29 | val newChat = ChatRepository.create(4, "Some chat", "private") 30 | val learnService = new LearnService(List("hello", "world", "scala"), newChat.id) 31 | 32 | learnService.learnPair() 33 | 34 | val storySetvice = new StoryService(List("hello", "world", "scala"), List(), newChat.id) 35 | 36 | val message = storySetvice.generate() 37 | 38 | assert(message.isEmpty) 39 | } 40 | 41 | it should "generate empty on empty dictionary" in { implicit session => 42 | val newChat = ChatRepository.create(4, "Some chat", "private") 43 | 44 | val storySetvice = new StoryService(List("hello", "world", "scala"), List(), newChat.id) 45 | 46 | val message = storySetvice.generate() 47 | 48 | assert(message.isEmpty) 49 | } 50 | 51 | it should "generate non-empty message" in { implicit session => 52 | val newChat = ChatRepository.create(4, "Some chat", "private") 53 | 54 | new LearnService(List("hello", "world", "scala"), newChat.id).learnPair() 55 | 56 | var timeOffset = new DateTime().minusMinutes(10) 57 | 58 | sql"UPDATE pairs SET created_at = ${timeOffset}".update.apply() 59 | 60 | val storySetvice = new StoryService(List("hello", "world", "scala"), List(), newChat.id) 61 | 62 | val message = storySetvice.generate() 63 | 64 | assert(message.nonEmpty) 65 | assert(message.get.contains("hello world scala")) 66 | } 67 | } -------------------------------------------------------------------------------- /core/src/main/scala/com/pepeground/core/repositories/WordRepository.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.core.repositories 2 | 3 | import com.pepeground.core.entities.WordEntity 4 | import scalikejdbc._ 5 | import com.pepeground.core.support.PostgreSQLSyntaxSupport._ 6 | import scala.util.control.Breaks._ 7 | import com.typesafe.scalalogging._ 8 | import org.slf4j.LoggerFactory 9 | 10 | object WordRepository { 11 | private val logger = Logger(LoggerFactory.getLogger(this.getClass)) 12 | private val w = WordEntity.syntax("w") 13 | 14 | def getFirstId()(implicit session: DBSession): Option[Long] = { 15 | withSQL { 16 | select(sqls"MIN(id)").from(WordEntity as w) 17 | }.map(rs => rs.long(1)).single.apply() 18 | } 19 | 20 | def getNextId(id: Long)(implicit session: DBSession): Option[Long] = { 21 | withSQL { 22 | select(w.id).from(WordEntity as w).where.gt(w.id, id).orderBy(w.id.asc).limit(1) 23 | }.map(rs => rs.long("id")).single.apply() 24 | } 25 | 26 | def deleteById(id: Long)(implicit session: DBSession): Unit = { 27 | withSQL { 28 | delete.from(WordEntity as w).where.eq(w.id, id) 29 | }.update().apply() 30 | } 31 | 32 | def getByWords(words: List[String])(implicit session: DBSession): List[WordEntity] = { 33 | withSQL { 34 | select.from(WordEntity as w).where.in(w.word, words) 35 | }.map(rs => WordEntity(w)(rs)).list.apply() 36 | } 37 | 38 | def getWordById(id: Long)(implicit session: DBSession): Option[WordEntity] = { 39 | withSQL { 40 | select.from(WordEntity as w).where.eq(w.id, id).limit(1) 41 | }.map(rs => WordEntity(w)(rs)).single.apply() 42 | } 43 | 44 | def getByWord(word: String)(implicit session: DBSession): Option[WordEntity] = { 45 | withSQL { 46 | select.from(WordEntity as w).where.eq(w.word, word).limit(1) 47 | }.map(rs => WordEntity(w)(rs)).single.apply() 48 | } 49 | 50 | def create(word: String)(implicit session: DBSession): Option[Long] = { 51 | withSQL { 52 | insert.into(WordEntity).namedValues( 53 | WordEntity.column.word -> word 54 | ).onConflictDoNothing().returningId 55 | }.map(rs => rs.long("id")).single().apply() 56 | } 57 | 58 | def learnWords(words: List[String])(implicit session: DBSession): Unit = { 59 | val existedWords: List[String] = getByWords(words).map(_.word) 60 | 61 | words.foreach { word => 62 | breakable { 63 | if (existedWords.contains(word)) break 64 | logger.info("Learn new word: %s".format(word)) 65 | create(word) 66 | } 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /core/src/test/scala/com/pepeground/core/repositories/WordRepositoryTest.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.core.repositories 2 | 3 | import org.flywaydb.core.Flyway 4 | import scalikejdbc.scalatest.AutoRollback 5 | import org.scalatest._ 6 | import org.scalatest.fixture.FlatSpec 7 | import scalikejdbc.ConnectionPool 8 | import scalikejdbc.config.DBs 9 | 10 | class WordRepositoryTest extends FlatSpec with BeforeAndAfter with AutoRollback { 11 | before { 12 | DBs.setupAll() 13 | 14 | val flyway: Flyway = new Flyway() 15 | val dataSource = ConnectionPool.dataSource(ConnectionPool.DEFAULT_NAME) 16 | 17 | flyway.setDataSource(dataSource) 18 | flyway.baseline() 19 | flyway.migrate() 20 | } 21 | 22 | behavior of "create" 23 | 24 | it should "creates new word" in { implicit session => 25 | val word = WordRepository.create("scala") 26 | 27 | assert(word.nonEmpty) 28 | } 29 | 30 | behavior of "getWordById" 31 | 32 | it should "return word by id" in { implicit session => 33 | val word = WordRepository.create("scala") 34 | val sameWord = WordRepository.getWordById(word.get) 35 | 36 | assert(sameWord.nonEmpty) 37 | assert(sameWord.get.word == "scala") 38 | } 39 | 40 | behavior of "getByWord" 41 | 42 | it should "return word by string" in { implicit session => 43 | val word = WordRepository.create("scala") 44 | 45 | val sameWord = WordRepository.getByWord("scala") 46 | 47 | assert(sameWord.nonEmpty) 48 | assert(word.get == sameWord.get.id) 49 | } 50 | 51 | behavior of "getByWords" 52 | 53 | it should "return list of words" in { implicit session => 54 | val word1 = WordRepository.create("hello") 55 | val word2 = WordRepository.create("world") 56 | val word3 = WordRepository.create("scala") 57 | 58 | val wordIds = WordRepository.getByWords(List("hello", "world", "scala")).map(_.id) 59 | 60 | assert(wordIds == List(word1, word2, word3).map(_.get)) 61 | } 62 | 63 | behavior of "learnWords" 64 | 65 | it should "learn new words" in { implicit session => 66 | WordRepository.learnWords(List("hello", "world", "scala")) 67 | 68 | val words = WordRepository.getByWords(List("hello", "world", "scala")) 69 | 70 | assert(words.size == 3) 71 | } 72 | 73 | it should "skip already learned words" in { implicit session => 74 | val word2 = WordRepository.create("world") 75 | WordRepository.learnWords(List("hello", "world", "scala")) 76 | 77 | val words = WordRepository.getByWords(List("hello", "world", "scala")) 78 | 79 | assert(words.size == 3) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /core/src/main/scala/com/pepeground/core/repositories/ChatRepository.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.core.repositories 2 | 3 | import java.util.NoSuchElementException 4 | 5 | import com.pepeground.core.enums.{ChatType} 6 | import com.pepeground.core.entities.ChatEntity 7 | import scalikejdbc._ 8 | import org.joda.time._ 9 | import com.pepeground.core.support.PostgreSQLSyntaxSupport._ 10 | import scalikejdbc.jodatime.JodaParameterBinderFactory._ 11 | 12 | object ChatRepository { 13 | private val c = ChatEntity.syntax("c") 14 | 15 | def getList(limit: Int = 20, offset: Int = 0)(implicit session: DBSession): List[ChatEntity] = { 16 | withSQL { 17 | select.from(ChatEntity as c).limit(limit).offset(offset) 18 | }.map(rs => ChatEntity(c)(rs)).list().apply() 19 | } 20 | 21 | def getChatById(id: Long)(implicit session: DBSession): Option[ChatEntity] = { 22 | withSQL { 23 | select.from(ChatEntity as c).where.eq(c.id, id).limit(1) 24 | }.map(rs => ChatEntity(c)(rs)).single.apply() 25 | } 26 | 27 | def getOrCreateBy(telegramId: Long, name: String, chatType: String)(implicit session: DBSession): ChatEntity = { 28 | getByTelegramId(telegramId) match { 29 | case Some(chat: ChatEntity) => chat 30 | case None => create(telegramId, name, chatType) 31 | } 32 | } 33 | 34 | def updateRandomChance(id: Long, randomChance: Int)(implicit session: DBSession): Unit = { 35 | withSQL { 36 | update(ChatEntity).set( 37 | ChatEntity.column.randomChance -> randomChance, 38 | ChatEntity.column.updatedAt -> new DateTime() 39 | ).where.eq(ChatEntity.column.id, id) 40 | }.update().apply() 41 | } 42 | 43 | def updateRepostChat(id: Long, repostChatUsername: String)(implicit session: DBSession): Unit = { 44 | withSQL { 45 | update(ChatEntity).set( 46 | ChatEntity.column.repostChatUsername -> repostChatUsername, 47 | ChatEntity.column.updatedAt -> new DateTime() 48 | ).where.eq(ChatEntity.column.id, id) 49 | }.update().apply() 50 | } 51 | 52 | def updateChat(id: Long, name: Option[String], telegramId: Long)(implicit session: DBSession): Unit = { 53 | withSQL { 54 | update(ChatEntity).set( 55 | ChatEntity.column.name -> name, 56 | ChatEntity.column.telegramId -> telegramId, 57 | ChatEntity.column.updatedAt -> new DateTime() 58 | ).where.eq(ChatEntity.column.id, id) 59 | }.update.apply() 60 | } 61 | 62 | def create(telegramId: Long, name: String, chatType: String)(implicit session: DBSession): ChatEntity = { 63 | withSQL { 64 | insert.into(ChatEntity).namedValues( 65 | ChatEntity.column.telegramId -> telegramId, 66 | ChatEntity.column.name -> Option(name), 67 | ChatEntity.column.chatType -> ChatType(chatType.toLowerCase), 68 | ChatEntity.column.updatedAt -> new DateTime(), 69 | ChatEntity.column.createdAt -> new DateTime() 70 | ).onConflictDoNothing() 71 | }.update().apply() 72 | 73 | getByTelegramId(telegramId) match { 74 | case Some(chat: ChatEntity) => chat 75 | case None => throw new NoSuchElementException("No such chat") 76 | } 77 | } 78 | 79 | def getByTelegramId(telegramId: Long)(implicit session: DBSession): Option[ChatEntity] = { 80 | withSQL { 81 | select.from(ChatEntity as c).where.eq(c.telegramId, telegramId).limit(1) 82 | }.map(rs => ChatEntity(c)(rs)).single.apply() 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /core/src/main/scala/com/pepeground/core/services/StoryService.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.core.services 2 | 3 | import com.pepeground.core.CoreConfig 4 | import com.pepeground.core.entities.{PairEntity, ReplyEntity, WordEntity} 5 | import com.pepeground.core.repositories.{PairRepository, ReplyRepository, WordRepository} 6 | import scalikejdbc._ 7 | 8 | import scala.collection.mutable.ListBuffer 9 | import scala.util.Random 10 | import scala.util.control.Breaks._ 11 | 12 | class StoryService(words: List[String], context: List[String], chatId: Long, sentences: Option[Int] = None)(implicit session: DBSession) { 13 | var currentSentences: ListBuffer[String] = ListBuffer() 14 | var currentWordIds: ListBuffer[Long] = ListBuffer() 15 | 16 | def generate(): Option[String] = { 17 | val currentWords: Map[String, Long] = WordRepository.getByWords(words ++ context).map(w => w.word -> w.id).toMap 18 | currentWordIds = words.map(w => currentWords.get(w)).filter(_.isDefined).map(_.get).to[ListBuffer] 19 | 20 | for ( a <- 0 to sentences.getOrElse(Random.nextInt(2) + 1) ) { 21 | generateSentence() 22 | } 23 | 24 | if (currentSentences.nonEmpty) { 25 | Some(currentSentences.mkString(" ")) 26 | } else { 27 | None 28 | } 29 | } 30 | 31 | 32 | private def generateSentence(): Unit = { 33 | var sentence: ListBuffer[String] = ListBuffer() 34 | var safetyCounter = 50 35 | 36 | var firstWordId: Option[Long] = None 37 | var secondWordId: List[Option[Long]] = currentWordIds.map(Option(_)).toList 38 | 39 | var pair: Option[PairEntity] = None 40 | 41 | pair = Random.shuffle(PairRepository.getPairWithReplies(chatId, firstWordId, secondWordId)).headOption 42 | 43 | breakable { 44 | while(true) { 45 | if ( safetyCounter < 0 ) break 46 | if ( pair.isEmpty ) break 47 | 48 | safetyCounter -= 1 49 | 50 | pair match { 51 | case Some(pe: PairEntity) => 52 | val reply = Random.shuffle(ReplyRepository.repliesForPair(pe.id)).headOption 53 | 54 | firstWordId = pe.secondId 55 | 56 | WordRepository.getWordById(pe.secondId.getOrElse(0.toLong)) match { 57 | case Some(we: WordEntity) => 58 | if(sentence.isEmpty) { 59 | sentence += we.word.toLowerCase 60 | currentWordIds -= pe.secondId.getOrElse(0) 61 | } 62 | case None => 63 | } 64 | 65 | reply match { 66 | case Some(re: ReplyEntity) => re.wordId match { 67 | case Some(wordId: Long) => 68 | secondWordId = List(re.wordId) 69 | 70 | WordRepository.getWordById(wordId) match { 71 | case Some(we: WordEntity) => 72 | sentence += we.word 73 | case None => 74 | break 75 | } 76 | case None => 77 | } 78 | case None => 79 | } 80 | case None => 81 | break 82 | } 83 | 84 | pair = Random.shuffle(PairRepository.getPairWithReplies(chatId, firstWordId, secondWordId)).headOption 85 | } 86 | } 87 | 88 | if (sentence.nonEmpty) { 89 | currentSentences += setSentenceEnd(sentence.mkString(" ").stripLineEnd) 90 | } 91 | } 92 | 93 | private def setSentenceEnd(s: String): String = { 94 | if(CoreConfig.punctuation.endSentence.contains(s.takeRight(1))) { 95 | s 96 | } else { 97 | "%s%s".format( 98 | s, 99 | CoreConfig.punctuation.endSentence(Random.nextInt(endSentenceLength)) 100 | ) 101 | } 102 | } 103 | 104 | lazy val endSentenceLength: Int = CoreConfig.punctuation.endSentence.length 105 | } -------------------------------------------------------------------------------- /bot/src/main/scala/com/pepeground/bot/handlers/GenericHandler.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.bot.handlers 2 | 3 | import java.util.concurrent.atomic.AtomicInteger 4 | 5 | import com.pepeground.bot.{Config, Prometheus} 6 | import com.pepeground.core.entities.ChatEntity 7 | import com.pepeground.core.repositories.{ChatRepository, ContextRepository} 8 | import com.bot4s.telegram.models.{Message, MessageEntity, User} 9 | import com.bot4s.telegram.models.ChatType._ 10 | import io.micrometer.core.instrument.Tag 11 | import scalikejdbc._ 12 | 13 | import scala.util.Random 14 | 15 | class GenericHandler(message: Message)(implicit session: DBSession) { 16 | private val messagesCounter = Prometheus.prometheusRegistry.counter("bot_messages") 17 | 18 | def before(): Unit = { 19 | if (isChatChanged) ChatRepository.updateChat(chat.id, Option(chatName), migrationId) 20 | 21 | messagesCounter.increment() 22 | } 23 | 24 | def isChatChanged: Boolean = chatName != chat.name.getOrElse("") || migrationId != telegramId 25 | def isPrivate: Boolean = chatType == "chat" 26 | def isRandomAnswer: Boolean = scala.util.Random.nextInt(100) < chat.randomChance 27 | 28 | def isReplyToBot: Boolean = message.replyToMessage match { 29 | case Some(r: Message) => r.from match { 30 | case Some(f: User) => f.username.getOrElse("") == Config.bot.name 31 | case None => false 32 | } 33 | case None => false 34 | } 35 | 36 | def hasAnchors: Boolean = { 37 | hasText && (words.exists(Config.bot.anchors.contains(_)) || text.getOrElse("").contains(Config.bot.name)) 38 | } 39 | 40 | def hasEntities: Boolean = message.entities match { 41 | case Some(s: Seq[MessageEntity]) => s.nonEmpty 42 | case None => false 43 | } 44 | 45 | def isEdition: Boolean = message.editDate match { 46 | case Some(_) => true 47 | case None => false 48 | } 49 | 50 | def hasText: Boolean = text match { 51 | case Some(t: String) => t.nonEmpty 52 | case None => false 53 | } 54 | 55 | def isMentioned: Boolean = text match { 56 | case Some(t: String) => t.contains(s"@${Config.bot.name}") 57 | case None => false 58 | } 59 | 60 | def isCommand: Boolean = text match { 61 | case Some(t: String) => t.startsWith("/") 62 | case None => false 63 | } 64 | 65 | def getWords(): List[String] = { 66 | var textCopy: String = text match { 67 | case Some(s: String) => s 68 | case None => return List() 69 | } 70 | 71 | message.entities match { 72 | case Some(s: Seq[MessageEntity]) => s.foreach { entity => 73 | textCopy = textCopy 74 | .replace(textCopy.substring(entity.offset, entity.offset + entity.length), " " * entity.length) 75 | } 76 | case _ => 77 | } 78 | textCopy 79 | .split("\\s+") 80 | .filterNot(s => s == " " || s.isEmpty || s.length > 2000) 81 | .map(_.toLowerCase) 82 | .toList 83 | } 84 | 85 | lazy val chat: ChatEntity = DB localTx { implicit session => ChatRepository.getOrCreateBy(telegramId, chatName, chatType) } 86 | lazy val telegramId: Long = message.chat.id 87 | lazy val migrationId: Long = message.migrateToChatId.getOrElse(telegramId) 88 | lazy val chatType: String = message.chat.`type` match { 89 | case Private => "chat" 90 | case Supergroup => "supergroup" 91 | case Group => "faction" 92 | case Channel => "channel" 93 | case _ => "chat" 94 | } 95 | lazy val chatName: String = message.chat.title.getOrElse(fromUsername) 96 | lazy val fromUsername: String = message.from match { 97 | case Some(u: User) => u.username.getOrElse("Unknown") 98 | case None => "Unknown" 99 | } 100 | 101 | lazy val context: List[String] = Random.shuffle(ContextRepository.getContext(chatContext, 10)).take(3) 102 | lazy val fullContext: List[String] = Random.shuffle(ContextRepository.getContext(chatContext, 50)) 103 | lazy val words: List[String] = getWords() 104 | lazy val text: Option[String] = message.text 105 | lazy val chatContext: String = s"chat_context/${chat.id}" 106 | } 107 | -------------------------------------------------------------------------------- /core/src/test/scala/com/pepeground/core/repositories/ChatRepositoryTest.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.core.repositories 2 | 3 | import com.pepeground.core.enums.ChatType 4 | import org.flywaydb.core.Flyway 5 | import scalikejdbc._ 6 | import scalikejdbc.scalatest.AutoRollback 7 | import org.scalatest._ 8 | import org.scalatest.fixture.FlatSpec 9 | 10 | import scalikejdbc.ConnectionPool 11 | import scalikejdbc.config.DBs 12 | 13 | class ChatRepositoryTest extends FlatSpec with BeforeAndAfter with AutoRollback { 14 | before { 15 | DBs.setupAll() 16 | 17 | val flyway: Flyway = new Flyway() 18 | val dataSource = ConnectionPool.dataSource(ConnectionPool.DEFAULT_NAME) 19 | 20 | flyway.setDataSource(dataSource) 21 | flyway.baseline() 22 | flyway.migrate() 23 | } 24 | 25 | behavior of "getList" 26 | 27 | it should "return list of chats" in { implicit session => 28 | val chat1 = ChatRepository.create(1, "Some chat 1", "private") 29 | val chat2 = ChatRepository.create(2, "Some chat 2", "private") 30 | 31 | val chatIds = ChatRepository.getList().map(_.id) 32 | assert(chatIds == List(chat1.id, chat2.id)) 33 | } 34 | 35 | behavior of "create" 36 | 37 | it should "create new chat" in { implicit session => 38 | val newChat = ChatRepository.create(1, "Some chat", "private") 39 | assert(newChat.telegramId == 1) 40 | assert(newChat.name.get == "Some chat") 41 | assert(newChat.chatType == ChatType("private")) 42 | } 43 | 44 | behavior of "getChatById" 45 | 46 | it should "return chat by id" in { implicit session => 47 | val newChat = ChatRepository.create(1, "Some chat", "private") 48 | val chat = ChatRepository.getChatById(newChat.id) 49 | 50 | assert(newChat.id == chat.get.id) 51 | } 52 | 53 | behavior of "getChatById" 54 | 55 | it should "return exited chat if chat already existed" in { implicit session => 56 | val newChat = ChatRepository.create(1, "Some chat", "private") 57 | val chat = ChatRepository.getOrCreateBy(newChat.telegramId, newChat.name.get, ChatType(newChat.chatType)) 58 | 59 | assert(chat.id == newChat.id) 60 | } 61 | 62 | it should "return new chat if chat not existed" in { implicit session => 63 | val newChat = ChatRepository.create(1, "Some chat", "private") 64 | val chat = ChatRepository.getOrCreateBy(2, newChat.name.get, ChatType(newChat.chatType)) 65 | 66 | assert(chat.id != newChat.id) 67 | } 68 | 69 | behavior of "updateRandomChance" 70 | 71 | it should "update random chance" in { implicit session => 72 | val newChat = ChatRepository.create(1, "Some chat", "private") 73 | ChatRepository.updateRandomChance(newChat.id, 50) 74 | 75 | val updatedChat = ChatRepository.getChatById(newChat.id) 76 | 77 | assert(newChat.randomChance != updatedChat.get.randomChance) 78 | } 79 | 80 | behavior of "updateRepostChat" 81 | 82 | it should "update repost chat" in { implicit session => 83 | val newChat = ChatRepository.create(1, "Some chat", "private") 84 | ChatRepository.updateRepostChat(newChat.id, "@ti_pidor") 85 | 86 | val updatedChat = ChatRepository.getChatById(newChat.id) 87 | 88 | assert(newChat.repostChatUsername != updatedChat.get.repostChatUsername) 89 | } 90 | 91 | behavior of "updateChat" 92 | 93 | it should "update chat" in { implicit session => 94 | val newChat = ChatRepository.create(1, "Some chat", "private") 95 | ChatRepository.updateChat(newChat.id, Some("KEK"), 3) 96 | val updatedChat = ChatRepository.getChatById(newChat.id) 97 | 98 | assert(newChat.id == updatedChat.get.id) 99 | assert(newChat.name != updatedChat.get.name) 100 | assert(newChat.telegramId != updatedChat.get.telegramId) 101 | } 102 | 103 | behavior of "getByTelegramId" 104 | 105 | it should "return chat by telegram id" in { implicit session => 106 | val newChat = ChatRepository.create(4, "Some chat", "private") 107 | val byTelegramId = ChatRepository.getByTelegramId(newChat.telegramId) 108 | 109 | assert(newChat.id == byTelegramId.get.id) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /core/src/main/scala/com/pepeground/core/repositories/PairRepository.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.core.repositories 2 | 3 | import java.util.NoSuchElementException 4 | 5 | import com.pepeground.core.entities.{PairEntity, ReplyEntity} 6 | import org.joda.time.DateTime 7 | import scalikejdbc._ 8 | import sqls.{count, distinct} 9 | import com.pepeground.core.support.PostgreSQLSyntaxSupport._ 10 | import com.typesafe.scalalogging._ 11 | import org.slf4j.LoggerFactory 12 | import scalikejdbc.jodatime.JodaParameterBinderFactory._ 13 | 14 | object PairRepository { 15 | private val logger = Logger(LoggerFactory.getLogger(this.getClass)) 16 | private val p = PairEntity.syntax("p") 17 | private val r = ReplyEntity.syntax("r") 18 | 19 | def hasWithWordId(wordId: Long)(implicit session: DBSession): Boolean = { 20 | withSQL { 21 | select(p.id).from(PairEntity as p).where.eq(p.firstId, wordId).or.eq(p.secondId, wordId).limit(1) 22 | }.map(rs => rs.long("id")).single.apply().isDefined 23 | } 24 | 25 | def getPairWithReplies(chatId: Long, firstIds: Option[Long], secondIds: List[Option[Long]])(implicit session: DBSession): List[PairEntity] = { 26 | var timeOffset = new DateTime 27 | 28 | timeOffset = timeOffset.minusMinutes(10) 29 | 30 | withSQL { 31 | select 32 | .from(PairEntity as p) 33 | .where.exists(select.from(ReplyEntity as r).where.eq(r.pairId, p.id)) 34 | .and.lt(p.createdAt, timeOffset) 35 | .and.eq(p.chatId, chatId) 36 | .and.eq(p.firstId, firstIds) 37 | .and.in(p.secondId, secondIds) 38 | .limit(3) 39 | }.map(rs => PairEntity(p)(rs)).list().apply() 40 | } 41 | 42 | def getPairBy(chatId: Long, firstId: Option[Long], secondId: Option[Long])(implicit session: DBSession): Option[PairEntity] = { 43 | withSQL { 44 | select.from(PairEntity as p).where.eq(p.chatId, chatId).and.eq(p.firstId, firstId).and.eq(p.secondId, secondId).limit(1) 45 | }.map(rs => PairEntity(p)(rs)).single.apply() 46 | } 47 | 48 | def createPairBy(chatId: Long, firstId: Option[Long], secondId: Option[Long], updatedAt: DateTime = new DateTime())(implicit session: DBSession): PairEntity = { 49 | logger.info("Learn new pair for chat id %s".format(chatId)) 50 | 51 | withSQL { 52 | insert.into(PairEntity).namedValues( 53 | PairEntity.column.chatId -> chatId, 54 | PairEntity.column.firstId -> firstId, 55 | PairEntity.column.secondId -> secondId, 56 | PairEntity.column.createdAt -> new DateTime(), 57 | PairEntity.column.updatedAt -> updatedAt 58 | ).onConflictDoNothing() 59 | }.update().apply() 60 | 61 | getPairBy(chatId, firstId, secondId) match { 62 | case Some(pair: PairEntity) => pair 63 | case None => throw new NoSuchElementException("No such pair") 64 | } 65 | } 66 | 67 | def touch(pairIds: List[Long])(implicit session: DBSession): Unit = { 68 | withSQL { 69 | update(PairEntity).set( 70 | PairEntity.column.updatedAt -> new DateTime() 71 | ).where.in(PairEntity.column.id, pairIds) 72 | }.update().apply() 73 | } 74 | 75 | def getPairsCount(chatId: Long)(implicit session: DBSession): Int = { 76 | withSQL { 77 | select(count(distinct(p.id))).from(PairEntity as p).where.eq(p.chatId, chatId) 78 | }.map(_.int(1)).single.apply().get 79 | } 80 | 81 | def removeOld(cleanupLimit: Int)(implicit session: DBSession): List[Long] = { 82 | val removeLt = new DateTime() 83 | 84 | val toRemovalIds: List[Long] = withSQL { 85 | select(p.id) 86 | .from(PairEntity as p) 87 | .where.lt(p.updatedAt, removeLt.minusMonths(3)) 88 | .limit(cleanupLimit) 89 | }.map(_.long("id")).list().apply() 90 | 91 | withSQL { 92 | delete 93 | .from(PairEntity) 94 | .where 95 | .in( 96 | PairEntity.column.id, 97 | toRemovalIds 98 | ) 99 | }.update().apply() 100 | 101 | toRemovalIds 102 | } 103 | 104 | def getPairOrCreateBy(chatId: Long, firstId: Option[Long], secondId: Option[Long])(implicit session: DBSession) = { 105 | getPairBy(chatId, firstId, secondId) match { 106 | case Some(p: PairEntity) => p 107 | case None => createPairBy(chatId, firstId, secondId) 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /core/src/test/scala/com/pepeground/core/repositories/PairRepositoryTest.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.core.repositories 2 | 3 | import org.flywaydb.core.Flyway 4 | import org.joda.time.DateTime 5 | import scalikejdbc.scalatest.AutoRollback 6 | import org.scalatest._ 7 | import org.scalatest.fixture.FlatSpec 8 | import scalikejdbc.ConnectionPool 9 | import scalikejdbc.config.DBs 10 | 11 | class PairRepositoryTest extends FlatSpec with BeforeAndAfter with AutoRollback { 12 | before { 13 | DBs.setupAll() 14 | 15 | val flyway: Flyway = new Flyway() 16 | val dataSource = ConnectionPool.dataSource(ConnectionPool.DEFAULT_NAME) 17 | 18 | flyway.setDataSource(dataSource) 19 | flyway.baseline() 20 | flyway.migrate() 21 | } 22 | 23 | behavior of "createPairBy" 24 | 25 | it should "creates new pair" in { implicit session => 26 | val chat = ChatRepository.create(1, "Some chat", "private") 27 | val word1 = WordRepository.create("hello") 28 | val word2 = WordRepository.create("world") 29 | 30 | val pair = PairRepository.createPairBy(chat.id, word1, word2) 31 | 32 | assert(pair.firstId == word1) 33 | assert(pair.secondId == word2) 34 | } 35 | 36 | behavior of "getPairOrCreateBy" 37 | 38 | it should "return existed pair if pair already exists" in { implicit session => 39 | val chat = ChatRepository.create(1, "Some chat", "private") 40 | val word1 = WordRepository.create("hello") 41 | val word2 = WordRepository.create("world") 42 | 43 | val existedPair = PairRepository.createPairBy(chat.id, word1, word2) 44 | val pair = PairRepository.getPairOrCreateBy(chat.id, word1, word2) 45 | 46 | assert(existedPair.id == pair.id) 47 | } 48 | 49 | it should "return new pair if pair does not exists" in { implicit session => 50 | val chat = ChatRepository.create(1, "Some chat", "private") 51 | val word1 = WordRepository.create("hello") 52 | val word2 = WordRepository.create("world") 53 | val word3 = WordRepository.create("scala") 54 | 55 | val existedPair = PairRepository.createPairBy(chat.id, word1, word2) 56 | val pair = PairRepository.getPairOrCreateBy(chat.id, word1, word3) 57 | 58 | assert(existedPair.id != pair.id) 59 | } 60 | 61 | behavior of "getPairBy" 62 | 63 | it should "return pair" in { implicit session => 64 | val chat = ChatRepository.create(1, "Some chat", "private") 65 | 66 | val word1 = WordRepository.create("hello") 67 | val word2 = WordRepository.create("world") 68 | 69 | val existedPair = PairRepository.createPairBy(chat.id, word1, word2) 70 | val pair = PairRepository.getPairBy(chat.id, word1, word2) 71 | 72 | assert(existedPair.id == pair.get.id) 73 | } 74 | 75 | behavior of "touch" 76 | 77 | it should "update updated_at column" in { implicit session => 78 | val chat = ChatRepository.create(1, "Some chat", "private") 79 | 80 | val word1 = WordRepository.create("hello") 81 | val word2 = WordRepository.create("world") 82 | 83 | val pair = PairRepository.createPairBy(chat.id, word1, word2) 84 | 85 | PairRepository.touch(List(pair.id)) 86 | 87 | val updatedPair = PairRepository.getPairBy(pair.chatId, pair.firstId, pair.secondId) 88 | 89 | assert(updatedPair.nonEmpty) 90 | assert(updatedPair.get.updatedAt != pair.updatedAt) 91 | } 92 | 93 | behavior of "getPairsCount" 94 | 95 | it should "return pairs count" in { implicit session => 96 | val chat = ChatRepository.create(1, "Some chat", "private") 97 | 98 | val word1 = WordRepository.create("hello") 99 | val word2 = WordRepository.create("world") 100 | 101 | val pair = PairRepository.createPairBy(chat.id, word1, word2) 102 | 103 | assert(PairRepository.getPairsCount(chat.id) == 1) 104 | } 105 | 106 | behavior of "removeOld" 107 | 108 | it should "remove old pairs" in { implicit session => 109 | val chat = ChatRepository.create(1, "Some chat", "private") 110 | 111 | val word1 = WordRepository.create("hello") 112 | val word2 = WordRepository.create("world") 113 | 114 | PairRepository.createPairBy(chat.id, word1, word2, new DateTime().minusMonths(3)) 115 | 116 | assert(PairRepository.getPairsCount(chat.id) == 1) 117 | 118 | PairRepository.removeOld(1) 119 | 120 | assert(PairRepository.getPairsCount(chat.id) == 0) 121 | 122 | PairRepository.createPairBy(chat.id, word1, word2, new DateTime().minusMonths(1)) 123 | 124 | PairRepository.removeOld(1) 125 | 126 | assert(PairRepository.getPairsCount(chat.id) == 1) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /core/src/test/scala/com/pepeground/core/repositories/ReplyRepositoryTest.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.core.repositories 2 | 3 | import org.flywaydb.core.Flyway 4 | import scalikejdbc.scalatest.AutoRollback 5 | import org.scalatest._ 6 | import org.scalatest.fixture.FlatSpec 7 | import scalikejdbc.ConnectionPool 8 | import scalikejdbc.config.DBs 9 | 10 | class ReplyRepositoryTest extends FlatSpec with BeforeAndAfter with AutoRollback { 11 | before { 12 | DBs.setupAll() 13 | 14 | val flyway: Flyway = new Flyway() 15 | val dataSource = ConnectionPool.dataSource(ConnectionPool.DEFAULT_NAME) 16 | 17 | flyway.setDataSource(dataSource) 18 | flyway.baseline() 19 | flyway.migrate() 20 | } 21 | 22 | behavior of "createReplyBy" 23 | 24 | it should "creates new reply" in { implicit session => 25 | val chat = ChatRepository.create(1, "Some chat", "private") 26 | val word1 = WordRepository.create("hello") 27 | val word2 = WordRepository.create("world") 28 | val word3 = WordRepository.create("scala") 29 | 30 | val pair = PairRepository.createPairBy(chat.id, word1, word2) 31 | 32 | val reply = ReplyRepository.createReplyBy(pair.id, word3) 33 | 34 | assert(reply.pairId == pair.id) 35 | assert(reply.wordId == word3) 36 | } 37 | 38 | behavior of "getReplyBy" 39 | 40 | it should "return reply" in { implicit session => 41 | val chat = ChatRepository.create(1, "Some chat", "private") 42 | val word1 = WordRepository.create("hello") 43 | val word2 = WordRepository.create("world") 44 | val word3 = WordRepository.create("scala") 45 | 46 | val pair = PairRepository.createPairBy(chat.id, word1, word2) 47 | 48 | val reply = ReplyRepository.createReplyBy(pair.id, word3) 49 | 50 | val sameReply = ReplyRepository.getReplyBy(pair.id, word3) 51 | 52 | assert(sameReply.nonEmpty) 53 | assert(sameReply.get.id == reply.id) 54 | } 55 | 56 | behavior of "incrementReply" 57 | 58 | it should "increment counter by 1" in { implicit session => 59 | val chat = ChatRepository.create(1, "Some chat", "private") 60 | val word1 = WordRepository.create("hello") 61 | val word2 = WordRepository.create("world") 62 | val word3 = WordRepository.create("scala") 63 | 64 | val pair = PairRepository.createPairBy(chat.id, word1, word2) 65 | 66 | val reply = ReplyRepository.createReplyBy(pair.id, word3) 67 | 68 | ReplyRepository.incrementReply(reply.id, reply.count) 69 | 70 | val sameReply = ReplyRepository.getReplyBy(pair.id, word3) 71 | 72 | assert(sameReply.nonEmpty) 73 | assert(sameReply.get.id == reply.id) 74 | assert(sameReply.get.count > reply.count) 75 | } 76 | 77 | behavior of "repliesForPair" 78 | 79 | it should "return pair replies" in { implicit session => 80 | val chat = ChatRepository.create(1, "Some chat", "private") 81 | val word1 = WordRepository.create("hello") 82 | val word2 = WordRepository.create("world") 83 | val word3 = WordRepository.create("scala") 84 | 85 | val pair = PairRepository.createPairBy(chat.id, word1, word2) 86 | 87 | val reply = ReplyRepository.createReplyBy(pair.id, word3) 88 | 89 | val replies = ReplyRepository.repliesForPair(pair.id).map(_.id) 90 | 91 | assert(replies == List(reply.id)) 92 | } 93 | 94 | behavior of "getReplyOrCreateBy" 95 | 96 | it should "return existed reply if reply exists" in { implicit session => 97 | val chat = ChatRepository.create(1, "Some chat", "private") 98 | val word1 = WordRepository.create("hello") 99 | val word2 = WordRepository.create("world") 100 | val word3 = WordRepository.create("scala") 101 | 102 | val pair = PairRepository.createPairBy(chat.id, word1, word2) 103 | 104 | val reply = ReplyRepository.createReplyBy(pair.id, word3) 105 | 106 | val sameReply = ReplyRepository.getReplyOrCreateBy(pair.id, word3) 107 | 108 | assert(reply.id == sameReply.id) 109 | } 110 | 111 | it should "create a new reply if reply does not exists" in { implicit session => 112 | val chat = ChatRepository.create(1, "Some chat", "private") 113 | val word1 = WordRepository.create("hello") 114 | val word2 = WordRepository.create("world") 115 | val word3 = WordRepository.create("scala") 116 | 117 | val pair = PairRepository.createPairBy(chat.id, word1, word2) 118 | 119 | val reply = ReplyRepository.createReplyBy(pair.id, word2) 120 | 121 | val newReply = ReplyRepository.getReplyOrCreateBy(pair.id, word3) 122 | 123 | assert(reply.id != newReply.id) 124 | } 125 | } -------------------------------------------------------------------------------- /core/src/test/scala/com/pepeground/core/services/LearnServiceTest.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.core.services 2 | 3 | import com.pepeground.core.repositories.{ChatRepository, PairRepository, ReplyRepository, WordRepository} 4 | import org.flywaydb.core.Flyway 5 | import scalikejdbc.scalatest.AutoRollback 6 | import org.scalatest._ 7 | import org.scalatest.fixture.FlatSpec 8 | import scalikejdbc.ConnectionPool 9 | import scalikejdbc.config.DBs 10 | 11 | class LearnServiceTest extends FlatSpec with BeforeAndAfter with AutoRollback { 12 | before { 13 | DBs.setupAll() 14 | 15 | val flyway: Flyway = new Flyway() 16 | val dataSource = ConnectionPool.dataSource(ConnectionPool.DEFAULT_NAME) 17 | 18 | flyway.setDataSource(dataSource) 19 | flyway.baseline() 20 | flyway.migrate() 21 | } 22 | 23 | behavior of "learn" 24 | 25 | it should "learn pairs" in { implicit session => 26 | val newChat = ChatRepository.create(4, "Some chat", "private") 27 | val service = new LearnService(List("hello", "world", "scala"), newChat.id) 28 | 29 | service.learnPair() 30 | 31 | val word1 = WordRepository.getByWord("hello") 32 | val word2 = WordRepository.getByWord("world") 33 | val word3 = WordRepository.getByWord("scala") 34 | 35 | assert(word1.nonEmpty) 36 | assert(word2.nonEmpty) 37 | assert(word3.nonEmpty) 38 | 39 | assert(PairRepository.getPairsCount(newChat.id) == 5) 40 | 41 | val pair1 = PairRepository.getPairBy(newChat.id, None, Some(word1.get.id)) 42 | val pair2 = PairRepository.getPairBy(newChat.id, Some(word1.get.id), Some(word2.get.id)) 43 | val pair3 = PairRepository.getPairBy(newChat.id, Some(word2.get.id), Some(word3.get.id)) 44 | val pair4 = PairRepository.getPairBy(newChat.id, Some(word3.get.id), None) 45 | val pair5 = PairRepository.getPairBy(newChat.id, None, None) 46 | 47 | assert(pair1.nonEmpty) 48 | assert(pair2.nonEmpty) 49 | assert(pair3.nonEmpty) 50 | assert(pair4.nonEmpty) 51 | assert(pair5.nonEmpty) 52 | 53 | val reply1 = ReplyRepository.getReplyBy(pair1.get.id, Some(word2.get.id)) 54 | val reply2 = ReplyRepository.getReplyBy(pair2.get.id, Some(word3.get.id)) 55 | val reply3 = ReplyRepository.getReplyBy(pair3.get.id, None) 56 | val reply4 = ReplyRepository.getReplyBy(pair4.get.id, None) 57 | val reply5 = ReplyRepository.getReplyBy(pair5.get.id, None) 58 | 59 | assert(reply1.nonEmpty) 60 | assert(reply2.nonEmpty) 61 | assert(reply3.nonEmpty) 62 | assert(reply4.nonEmpty) 63 | assert(reply5.nonEmpty) 64 | } 65 | 66 | it should "learn pairs once" in { implicit session => 67 | 0 to 1 foreach { i => 68 | val newChat = ChatRepository.create(4, "Some chat", "private") 69 | val service = new LearnService(List("hello", "world", "scala"), newChat.id) 70 | 71 | service.learnPair() 72 | 73 | val word1 = WordRepository.getByWord("hello") 74 | val word2 = WordRepository.getByWord("world") 75 | val word3 = WordRepository.getByWord("scala") 76 | 77 | assert(word1.nonEmpty) 78 | assert(word2.nonEmpty) 79 | assert(word3.nonEmpty) 80 | 81 | assert(PairRepository.getPairsCount(newChat.id) == 5) 82 | 83 | val pair1 = PairRepository.getPairBy(newChat.id, None, Some(word1.get.id)) 84 | val pair2 = PairRepository.getPairBy(newChat.id, Some(word1.get.id), Some(word2.get.id)) 85 | val pair3 = PairRepository.getPairBy(newChat.id, Some(word2.get.id), Some(word3.get.id)) 86 | val pair4 = PairRepository.getPairBy(newChat.id, Some(word3.get.id), None) 87 | val pair5 = PairRepository.getPairBy(newChat.id, None, None) 88 | 89 | assert(pair1.nonEmpty) 90 | assert(pair2.nonEmpty) 91 | assert(pair3.nonEmpty) 92 | assert(pair4.nonEmpty) 93 | assert(pair5.nonEmpty) 94 | 95 | val reply1 = ReplyRepository.getReplyBy(pair1.get.id, Some(word2.get.id)) 96 | val reply2 = ReplyRepository.getReplyBy(pair2.get.id, Some(word3.get.id)) 97 | val reply3 = ReplyRepository.getReplyBy(pair3.get.id, None) 98 | val reply4 = ReplyRepository.getReplyBy(pair4.get.id, None) 99 | val reply5 = ReplyRepository.getReplyBy(pair5.get.id, None) 100 | 101 | assert(reply1.nonEmpty) 102 | assert(reply2.nonEmpty) 103 | assert(reply3.nonEmpty) 104 | assert(reply4.nonEmpty) 105 | assert(reply5.nonEmpty) 106 | } 107 | } 108 | 109 | it should "learn pairs with words which contains punctuation chars" in { implicit session => 110 | val newChat = ChatRepository.create(4, "Some chat", "private") 111 | val service = new LearnService(List("hello", "world.", "scala"), newChat.id) 112 | 113 | service.learnPair() 114 | 115 | val word1 = WordRepository.getByWord("hello") 116 | val word2 = WordRepository.getByWord("world.") 117 | val word3 = WordRepository.getByWord("scala") 118 | 119 | assert(word1.nonEmpty) 120 | assert(word2.nonEmpty) 121 | assert(word3.nonEmpty) 122 | 123 | assert(PairRepository.getPairsCount(newChat.id) == 6) 124 | 125 | val pair1 = PairRepository.getPairBy(newChat.id, None, Some(word1.get.id)) 126 | val pair2 = PairRepository.getPairBy(newChat.id, Some(word1.get.id), Some(word2.get.id)) 127 | val pair3 = PairRepository.getPairBy(newChat.id, Some(word2.get.id), None) 128 | val pair4 = PairRepository.getPairBy(newChat.id, None, Some(word3.get.id)) 129 | val pair5 = PairRepository.getPairBy(newChat.id, Some(word3.get.id), None) 130 | val pair6 = PairRepository.getPairBy(newChat.id, None, None) 131 | 132 | assert(pair1.nonEmpty) 133 | assert(pair2.nonEmpty) 134 | assert(pair3.nonEmpty) 135 | assert(pair4.nonEmpty) 136 | assert(pair5.nonEmpty) 137 | assert(pair6.nonEmpty) 138 | 139 | val reply1 = ReplyRepository.getReplyBy(pair1.get.id, Some(word2.get.id)) 140 | val reply2 = ReplyRepository.getReplyBy(pair2.get.id, None) 141 | val reply3 = ReplyRepository.getReplyBy(pair3.get.id, Some(word3.get.id)) 142 | val reply4 = ReplyRepository.getReplyBy(pair4.get.id, None) 143 | val reply5 = ReplyRepository.getReplyBy(pair5.get.id, None) 144 | val reply6 = ReplyRepository.getReplyBy(pair6.get.id, None) 145 | 146 | assert(reply1.nonEmpty) 147 | assert(reply2.nonEmpty) 148 | assert(reply3.nonEmpty) 149 | assert(reply4.nonEmpty) 150 | assert(reply5.nonEmpty) 151 | assert(reply6.nonEmpty) 152 | } 153 | } -------------------------------------------------------------------------------- /bot/src/main/scala/com/pepeground/bot/Router.scala: -------------------------------------------------------------------------------- 1 | package com.pepeground.bot 2 | 3 | import com.bot4s.telegram.api.RequestHandler 4 | import com.bot4s.telegram.api.declarative.Commands 5 | import com.bot4s.telegram.clients.FutureSttpClient 6 | import com.pepeground.bot.handlers._ 7 | import com.bot4s.telegram.future.{Polling, TelegramBot} 8 | import com.bot4s.telegram.methods._ 9 | import com.bot4s.telegram.models._ 10 | 11 | import sttp.client3.okhttp.OkHttpFutureBackend 12 | 13 | import scala.util.{Failure, Success, Try} 14 | import scala.concurrent.Future 15 | import com.typesafe.scalalogging._ 16 | import scalikejdbc._ 17 | import slogging.{LogLevel, LoggerConfig, PrintLoggerFactory} 18 | 19 | object Router extends TelegramBot with Polling with Commands[Future] { 20 | def token = Config.bot.telegramToken 21 | 22 | LoggerConfig.factory = PrintLoggerFactory() 23 | LoggerConfig.level = LogLevel.ERROR 24 | 25 | 26 | implicit val backend = OkHttpFutureBackend() 27 | override val client: RequestHandler[Future] = new FutureSttpClient(token) 28 | 29 | private val botName = Config.bot.name.toLowerCase 30 | 31 | override def receiveMessage(msg: Message): Future[Unit] = { 32 | DB localTx { implicit session => 33 | Try(processMessage(msg)) match { 34 | case Success(_: Unit) => super.receiveMessage(msg) 35 | case Failure(e: Throwable) => throw e 36 | } 37 | } 38 | } 39 | 40 | private def processMessage(msg: Message)(implicit session: DBSession): Unit = { 41 | for (text <- msg.text) cleanCmd(text) match { 42 | case c if expectedCmd(c, "/repost") => RepostHandler(msg).call() match { 43 | case Some(s: ForwardMessage) => 44 | request(s) onComplete { 45 | case Success(_) => makeResponse(text, SendMessage(msg.source, "reposted", replyToMessageId = Some(msg.messageId))) 46 | case Failure(_) => 47 | } 48 | 49 | case None => 50 | } 51 | case c if expectedCmd(c, "/get_stats") => GetStatsHandler(msg).call() match { 52 | case Some(s: String) => makeResponse(text, SendMessage(msg.source, s, replyToMessageId = Some(msg.messageId))) 53 | case None => 54 | } 55 | case c if expectedCmd(c, "/cool_story") => CoolStoryHandler(msg).call() match { 56 | case Some(s: String) => makeResponse(text, SendMessage(msg.source, s)) 57 | case None => 58 | } 59 | case c if expectedCmd(c, "/set_gab") => 60 | val level: Option[Int] = text.split(" ").take(2) match { 61 | case Array(_, randomLevel) => Try(randomLevel.toInt).toOption match { 62 | case Some(l: Int) => Some(l) 63 | case None => None 64 | } 65 | case _ => None 66 | } 67 | 68 | level match { 69 | case Some(l: Int) => 70 | SetGabHandler(msg).call(l) match { 71 | case Some(s: String) => makeResponse(text, SendMessage(msg.source, s, replyToMessageId = Some(msg.messageId))) 72 | case None => 73 | } 74 | case None => makeResponse(text, SendMessage(msg.source, "Wrong percent", replyToMessageId = Some(msg.messageId))) 75 | } 76 | case c if expectedCmd(c, "/get_gab") => GetGabHandler(msg).call() match { 77 | case Some(s: String) => makeResponse(text, SendMessage(msg.source, s, replyToMessageId = Some(msg.messageId))) 78 | case None => 79 | } 80 | case c if expectedCmd(c, "/ping") => PingHandler(msg).call() match { 81 | case Some(s: String) => makeResponse(text, SendMessage(msg.source, s, replyToMessageId = Some(msg.messageId))) 82 | case None => 83 | } 84 | case c if expectedCmd(c, "/set_repost_channel") => { 85 | val chatUsername: Option[String] = msg.entities match { 86 | case Some(msgEntities: Seq[MessageEntity]) => { 87 | msgEntities.find { 88 | msgEntity: MessageEntity => msgEntity.`type` == "mention" 89 | } match { 90 | case Some(msgEntity: MessageEntity) => { 91 | val offset: Int = msgEntity.offset 92 | Some(text.substring(offset, offset + msgEntity.length)) 93 | } 94 | case None => None 95 | } 96 | } 97 | case _ => None 98 | } 99 | 100 | val chatMemberRequest: Option[Future[ChatMember]] = msg.from match { 101 | case Some(u: User) => Some(request(GetChatMember(msg.chat.id, u.id))) 102 | case None => None 103 | } 104 | 105 | chatUsername match { 106 | case Some(l: String) => 107 | SetRepostChatHandler(msg, chatMemberRequest).call(l) match { 108 | case Some(s: String) => makeResponse(text, SendMessage(msg.source, s, replyToMessageId = Some(msg.messageId))) 109 | case None => 110 | } 111 | case None => makeResponse(text, SendMessage(msg.source, "No chat username", replyToMessageId = Some(msg.messageId))) 112 | } 113 | } 114 | case c if expectedCmd(c, "/get_repost_channel") => GetRepostChatHandler(msg).call() match { 115 | case Some(s: String) => makeResponse(text, SendMessage(msg.source, s, replyToMessageId = Some(msg.messageId))) 116 | case None => 117 | } 118 | case s => 119 | if(!s.startsWith("/")) handleMessage(msg) 120 | } 121 | } 122 | 123 | private def cleanCmd(cmd: String): String = cmd.takeWhile(s => s != ' ' ).toLowerCase 124 | 125 | private def expectedCmd(cmd: String, expected: String): Boolean = { 126 | cmd.split("@") match { 127 | case Array(c: String, name: String) => c == expected && name.toLowerCase == botName 128 | case Array(c: String) => c == expected 129 | case _ => false 130 | } 131 | } 132 | 133 | private def handleMessage(msg: Message)(implicit session: DBSession): Unit = { 134 | MessageHandler(msg).call() match { 135 | case Some(res: Either[Option[String], Option[String]]) => res match { 136 | case Left(s: Option[String]) => if(s.nonEmpty) makeResponse("message", SendMessage(msg.source, s.get, replyToMessageId = Some(msg.messageId))) 137 | case Right(s: Option[String]) => if(s.nonEmpty) makeResponse("message", SendMessage(msg.source, s.get)) 138 | } 139 | case _ => 140 | } 141 | } 142 | 143 | private def makeResponse(context: String, msg: SendMessage): Unit = { 144 | request(msg) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /kubernetes/deployment.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | kind: Deployment 3 | apiVersion: apps/v1 4 | metadata: 5 | name: redis-master 6 | namespace: pepeground 7 | labels: 8 | app: redis 9 | spec: 10 | replicas: 1 11 | selector: 12 | matchLabels: 13 | app: redis 14 | template: 15 | metadata: 16 | creationTimestamp: null 17 | labels: 18 | app: redis 19 | spec: 20 | containers: 21 | - name: master 22 | image: 'redis:5.0' 23 | ports: 24 | - containerPort: 6379 25 | protocol: TCP 26 | resources: 27 | requests: 28 | cpu: 100m 29 | memory: 100Mi 30 | terminationMessagePath: /dev/termination-log 31 | terminationMessagePolicy: File 32 | imagePullPolicy: IfNotPresent 33 | restartPolicy: Always 34 | terminationGracePeriodSeconds: 30 35 | dnsPolicy: ClusterFirst 36 | securityContext: {} 37 | schedulerName: default-scheduler 38 | strategy: 39 | type: RollingUpdate 40 | rollingUpdate: 41 | maxUnavailable: 25% 42 | maxSurge: 25% 43 | revisionHistoryLimit: 10 44 | progressDeadlineSeconds: 600 45 | --- 46 | kind: Service 47 | apiVersion: v1 48 | metadata: 49 | name: redis 50 | namespace: pepeground 51 | spec: 52 | ports: 53 | - protocol: TCP 54 | port: 6379 55 | targetPort: 6379 56 | selector: 57 | app: redis 58 | clusterIP: 172.18.210.59 59 | type: ClusterIP 60 | sessionAffinity: None 61 | --- 62 | apiVersion: apps/v1 63 | kind: Deployment 64 | metadata: 65 | name: pepeground-bot 66 | namespace: pepeground 67 | spec: 68 | progressDeadlineSeconds: 600 69 | replicas: 1 70 | revisionHistoryLimit: 10 71 | selector: 72 | matchLabels: 73 | app: pepeground-bot 74 | strategy: 75 | rollingUpdate: 76 | maxSurge: 25% 77 | maxUnavailable: 25% 78 | type: RollingUpdate 79 | template: 80 | metadata: 81 | creationTimestamp: null 82 | labels: 83 | app: pepeground-bot 84 | spec: 85 | containers: 86 | - command: 87 | - /usr/local/openjdk-8/bin/java 88 | - -jar 89 | - -server 90 | - -Dconfig.file=/usr/local/etc/application.conf 91 | - /bot.jar 92 | - bot 93 | image: pepeground/pepeground-bot 94 | ports: 95 | - name: web 96 | containerPort: 8080 97 | protocol: TCP 98 | imagePullPolicy: Always 99 | name: pepeground-bot 100 | resources: {} 101 | terminationMessagePath: /dev/termination-log 102 | terminationMessagePolicy: File 103 | volumeMounts: 104 | - mountPath: /usr/local/etc 105 | name: pepeground-conf 106 | dnsPolicy: ClusterFirst 107 | restartPolicy: Always 108 | schedulerName: default-scheduler 109 | securityContext: {} 110 | terminationGracePeriodSeconds: 30 111 | volumes: 112 | - configMap: 113 | defaultMode: 420 114 | name: pepeground-conf 115 | name: pepeground-conf 116 | --- 117 | apiVersion: apps/v1 118 | kind: Deployment 119 | metadata: 120 | name: pepeground-learn 121 | namespace: pepeground 122 | spec: 123 | progressDeadlineSeconds: 600 124 | replicas: 1 125 | revisionHistoryLimit: 10 126 | selector: 127 | matchLabels: 128 | app: pepeground-learn 129 | template: 130 | metadata: 131 | creationTimestamp: null 132 | labels: 133 | app: pepeground-learn 134 | spec: 135 | containers: 136 | - command: 137 | - /usr/local/openjdk-8/bin/java 138 | - -jar 139 | - -server 140 | - -Dconfig.file=/usr/local/etc/application.conf 141 | - /bot.jar 142 | - learn 143 | image: pepeground/pepeground-bot 144 | ports: 145 | - name: web 146 | containerPort: 8080 147 | protocol: TCP 148 | imagePullPolicy: Always 149 | name: pepeground-learn 150 | resources: {} 151 | terminationMessagePath: /dev/termination-log 152 | terminationMessagePolicy: File 153 | volumeMounts: 154 | - mountPath: /usr/local/etc 155 | name: pepeground-conf 156 | resources: 157 | requests: 158 | cpu: "250m" 159 | dnsPolicy: ClusterFirst 160 | restartPolicy: Always 161 | schedulerName: default-scheduler 162 | securityContext: {} 163 | terminationGracePeriodSeconds: 30 164 | volumes: 165 | - configMap: 166 | defaultMode: 420 167 | name: pepeground-conf 168 | name: pepeground-conf 169 | --- 170 | apiVersion: apps/v1 171 | kind: Deployment 172 | metadata: 173 | name: pepeground-cleanup 174 | namespace: pepeground 175 | spec: 176 | progressDeadlineSeconds: 600 177 | replicas: 1 178 | revisionHistoryLimit: 10 179 | selector: 180 | matchLabels: 181 | app: pepeground-cleanup 182 | template: 183 | metadata: 184 | creationTimestamp: null 185 | labels: 186 | app: pepeground-cleanup 187 | spec: 188 | containers: 189 | - command: 190 | - /usr/local/openjdk-8/bin/java 191 | - -jar 192 | - -server 193 | - -Dconfig.file=/usr/local/etc/application.conf 194 | - /bot.jar 195 | - cleanup 196 | image: pepeground/pepeground-bot 197 | ports: 198 | - name: web 199 | containerPort: 8080 200 | protocol: TCP 201 | imagePullPolicy: Always 202 | name: pepeground-cleanup 203 | resources: {} 204 | terminationMessagePath: /dev/termination-log 205 | terminationMessagePolicy: File 206 | volumeMounts: 207 | - mountPath: /usr/local/etc 208 | name: pepeground-conf 209 | dnsPolicy: ClusterFirst 210 | restartPolicy: Always 211 | schedulerName: default-scheduler 212 | securityContext: {} 213 | terminationGracePeriodSeconds: 30 214 | volumes: 215 | - configMap: 216 | defaultMode: 420 217 | name: pepeground-conf 218 | name: pepeground-conf 219 | --- 220 | kind: Service 221 | apiVersion: v1 222 | metadata: 223 | name: prometheus-operator 224 | namespace: pepeground 225 | labels: 226 | app: pepeground-bot 227 | spec: 228 | ports: 229 | - name: web 230 | protocol: TCP 231 | port: 8080 232 | targetPort: web 233 | selector: 234 | app: pepeground-bot 235 | type: ClusterIP 236 | --- 237 | apiVersion: monitoring.coreos.com/v1 238 | kind: ServiceMonitor 239 | metadata: 240 | name: pepeground-bot 241 | namespace: pepeground 242 | spec: 243 | selector: 244 | matchLabels: 245 | app: pepeground-bot 246 | jobLabel: jobLabel 247 | namespaceSelector: 248 | matchNames: 249 | - pepeground 250 | endpoints: 251 | - port: web 252 | --------------------------------------------------------------------------------