feat: campaign draft, wip
continuous-integration/drone/push Build is passing Details

create-campaign
Davide Polonio 2023-05-10 22:50:29 +02:00
parent 5788d59516
commit d93fe8d3b9
15 changed files with 533 additions and 17 deletions

View File

@ -0,0 +1,60 @@
package com.github.polpetta.mezzotre.orm.model;
import io.ebean.annotation.Length;
import io.ebean.annotation.NotNull;
import java.util.List;
import javax.annotation.Nullable;
import javax.persistence.*;
import javax.validation.constraints.Null;
@Entity
public class Campaign extends Base {
@Id
@Length(64)
private final String id;
@Length(256)
@NotNull
private String campaignName;
@Nullable @Null private String description;
@ManyToMany(fetch = FetchType.LAZY)
private List<User> users;
public Campaign(String id, String campaignName, @Nullable String description) {
this.id = id;
this.campaignName = campaignName;
this.description = description;
}
public String getId() {
return id;
}
public String getCampaignName() {
return campaignName;
}
public void setCampaignName(String campaignName) {
this.campaignName = campaignName;
}
@Nullable
public String getDescription() {
return description;
}
public void setDescription(@Nullable String description) {
this.description = description;
}
public List<User> getUsers() {
return users;
}
public void setUsers(List<User> users) {
this.users = users;
}
}

View File

@ -4,6 +4,7 @@ import io.ebean.annotation.ConstraintMode;
import io.ebean.annotation.DbForeignKey; import io.ebean.annotation.DbForeignKey;
import io.ebean.annotation.Length; import io.ebean.annotation.Length;
import io.ebean.annotation.NotNull; import io.ebean.annotation.NotNull;
import java.util.List;
import javax.annotation.Nullable; import javax.annotation.Nullable;
import javax.persistence.*; import javax.persistence.*;
@ -22,17 +23,20 @@ public class User extends Base {
@OneToOne(fetch = FetchType.LAZY, optional = true) @OneToOne(fetch = FetchType.LAZY, optional = true)
@DbForeignKey(onDelete = ConstraintMode.CASCADE) @DbForeignKey(onDelete = ConstraintMode.CASCADE)
@JoinColumn(name = "telegram_id", referencedColumnName = "id") @JoinColumn(name = "telegram_id", referencedColumnName = "id")
private TgChat telegramId; private TgChat telegramChat;
@ManyToMany(fetch = FetchType.LAZY)
private List<Campaign> campaigns;
@Length(256) @Length(256)
@Nullable @Nullable
private String emailAddress; private String emailAddress;
public User(String id, String emailAddress, Boolean isActive, TgChat telegramId) { public User(String id, String emailAddress, Boolean isActive, TgChat telegramChat) {
this.id = id; this.id = id;
this.emailAddress = emailAddress; this.emailAddress = emailAddress;
this.isActive = isActive; this.isActive = isActive;
this.telegramId = telegramId; this.telegramChat = telegramChat;
} }
public String getId() { public String getId() {
@ -47,19 +51,28 @@ public class User extends Base {
isActive = active; isActive = active;
} }
public TgChat getTelegramId() { public TgChat getTelegramChat() {
return telegramId; return telegramChat;
} }
public void setTelegramId(TgChat telegramId) { public void setTelegramChat(TgChat telegramChat) {
this.telegramId = telegramId; this.telegramChat = telegramChat;
} }
@Nullable
public String getEmailAddress() { public String getEmailAddress() {
return emailAddress; return emailAddress;
} }
public void setEmailAddress(String emailAddress) { public void setEmailAddress(@Nullable String emailAddress) {
this.emailAddress = emailAddress; this.emailAddress = emailAddress;
} }
public List<Campaign> getCampaigns() {
return campaigns;
}
public void setCampaigns(List<Campaign> campaigns) {
this.campaigns = campaigns;
}
} }

View File

@ -0,0 +1,22 @@
package com.github.polpetta.mezzotre.orm.telegram;
import com.github.polpetta.mezzotre.orm.model.Campaign;
import com.github.polpetta.mezzotre.util.UUIDGenerator;
import javax.inject.Inject;
public class CampaignUtil {
private final UUIDGenerator uuidGenerator;
@Inject
public CampaignUtil(UUIDGenerator uuidGenerator) {
this.uuidGenerator = uuidGenerator;
}
public Campaign insertNewCampaign(String name, String description) {
final Campaign campaign = new Campaign(uuidGenerator.generateAsString(), name, description);
campaign.save();
return campaign;
}
}

View File

@ -34,18 +34,18 @@ public class ChatUtil {
* then saved in the database * then saved in the database
* *
* @param chat the chat that will be updated with the new {@link ChatContext} values * @param chat the chat that will be updated with the new {@link ChatContext} values
* @param stepName the step name to set * @param stageName the stage name to set
* @param stageNumber the stage number to set * @param stepNumber the step number to set
* @param additionalFields if there are, additional custom fields that will be added to {@link * @param additionalFields if there are, additional custom fields that will be added to {@link
* ChatContext}. Note that these values will have to be manually retrieved since no method is * ChatContext}. Note that these values will have to be manually retrieved since no method is
* available for custom entries. Use {@link Collections#emptyMap()} if you don't wish to add * available for custom entries. Use {@link Collections#emptyMap()} if you don't wish to add
* any additional field * any additional field
*/ */
public void updateChatContext( public void updateChatContext(
TgChat chat, String stepName, int stageNumber, Map<String, Object> additionalFields) { TgChat chat, String stageName, int stepNumber, Map<String, Object> additionalFields) {
final ChatContext chatContext = chat.getChatContext(); final ChatContext chatContext = chat.getChatContext();
chatContext.setStage(stepName); chatContext.setStage(stageName);
chatContext.setStep(stageNumber); chatContext.setStep(stepNumber);
chatContext.setPreviousMessageUnixTimestampInSeconds(clock.now()); chatContext.setPreviousMessageUnixTimestampInSeconds(clock.now());
additionalFields.forEach(chatContext::setAdditionalProperty); additionalFields.forEach(chatContext::setAdditionalProperty);

View File

@ -10,7 +10,7 @@ import java.util.Optional;
* @author Davide Polonio * @author Davide Polonio
* @since 1.0 * @since 1.0
*/ */
public class Util { class Util {
/** /**
* Extract the message id of the given {@link Update} * Extract the message id of the given {@link Update}
@ -19,7 +19,7 @@ public class Util {
* @return an {@link Optional} containing a {@link Integer} with the message id if it is present, * @return an {@link Optional} containing a {@link Integer} with the message id if it is present,
* otherwise a {@link Optional#empty()} if it is not found. * otherwise a {@link Optional#empty()} if it is not found.
*/ */
public static Optional<Integer> extractMessageId(Update update) { static Optional<Integer> extractMessageId(Update update) {
return Optional.ofNullable(update.callbackQuery().message()).map(Message::messageId); return Optional.ofNullable(update.callbackQuery().message()).map(Message::messageId);
} }
} }

View File

@ -37,10 +37,12 @@ public class CommandDI extends AbstractModule {
@Provides @Provides
@Singleton @Singleton
@Named("commandProcessor") @Named("commandProcessor")
public Map<String, Processor> getCommandProcessor(Start start, Help help) { public Map<String, Processor> getCommandProcessor(
Start start, Help help, CreateCampaign createCampaign) {
final HashMap<String, Processor> commandMap = new HashMap<>(); final HashMap<String, Processor> commandMap = new HashMap<>();
commandMap.putAll(mapForProcessor(start)); commandMap.putAll(mapForProcessor(start));
commandMap.putAll(mapForProcessor(help)); commandMap.putAll(mapForProcessor(help));
commandMap.putAll(mapForProcessor(createCampaign));
return commandMap; return commandMap;
} }

View File

@ -0,0 +1,174 @@
package com.github.polpetta.mezzotre.telegram.command;
import com.github.polpetta.mezzotre.i18n.TemplateContentGenerator;
import com.github.polpetta.mezzotre.orm.model.TgChat;
import com.github.polpetta.mezzotre.orm.telegram.CampaignUtil;
import com.github.polpetta.mezzotre.orm.telegram.ChatUtil;
import com.github.polpetta.mezzotre.util.UUIDGenerator;
import com.pengrad.telegrambot.model.Update;
import com.pengrad.telegrambot.model.request.ParseMode;
import com.pengrad.telegrambot.request.BaseRequest;
import com.pengrad.telegrambot.request.SendMessage;
import java.util.Collections;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import javax.inject.Inject;
import javax.inject.Named;
// FIXME tests, doc
public class CreateCampaign implements Processor {
private static final String TRIGGERING_KEYWORD = "/createCampaign";
// FIXME create the label
private static final String BUTTON_CREATE_IT_LOCALE_KEY = "createCampaign.createItButton";
private static final String CAMPAIGN_NAME_CHAT_CTX_FIELD = "campaign_name";
private static final String CAMPAIGN_NAME_TEMPLATE_CTX_FIELD = "campaignName";
private static final int MAX_CAMPAIGN_NAME_LENGTH = 256;
private static final int MAX_CAMPAIGN_DESCRIPTION_LENGTH = 4096;
private final TemplateContentGenerator templateContentGenerator;
private final Executor threadPool;
private final UUIDGenerator uuidGenerator;
private final ChatUtil chatUtil;
private final CampaignUtil campaignUtil;
@Inject
public CreateCampaign(
TemplateContentGenerator templateContentGenerator,
@Named("eventThreadPool") Executor threadPool,
UUIDGenerator uuidGenerator,
ChatUtil chatUtil,
CampaignUtil campaignUtil) {
this.templateContentGenerator = templateContentGenerator;
this.threadPool = threadPool;
this.uuidGenerator = uuidGenerator;
this.chatUtil = chatUtil;
this.campaignUtil = campaignUtil;
}
@Override
public Set<String> getTriggerKeywords() {
return Set.of(TRIGGERING_KEYWORD, "/newCampaign");
}
@Override
public CompletableFuture<Optional<BaseRequest<?, ?>>> process(TgChat chat, Update update) {
// There are multiple steps, we have to route the incoming campaign creation to the right one
return switch ((int) chat.getChatContext().getStep()) {
default -> promptCampaignName(chat);
case 1 -> verifyCampaignNameAndPromptCampaignDescription(chat, update);
case 2 -> verifyDescriptionAndCompleteCreation(chat, update);
};
}
private CompletableFuture<Optional<BaseRequest<?, ?>>> promptCampaignName(TgChat chat) {
return CompletableFuture.supplyAsync(
() ->
templateContentGenerator.mergeTemplate(
ctx -> {}, chat.getLocale(), "template/telegram/createCampaign.0.vm"),
threadPool)
.thenApplyAsync(
message -> {
chatUtil.updateChatContext(chat, TRIGGERING_KEYWORD, 1, Collections.emptyMap());
return Optional.of(
new SendMessage(chat.getId(), message).parseMode(ParseMode.Markdown));
},
threadPool);
}
private CompletableFuture<Optional<BaseRequest<?, ?>>>
verifyCampaignNameAndPromptCampaignDescription(TgChat chat, Update update) {
/*
Flow:
1 - Input validation (name length, etc...)
2 - Build message according to validation output
3 - Send message back
4 - ???
5 - Profit
*/
return CompletableFuture.completedFuture(chat)
.thenApply(
// 1 - Perform input validation
ignored ->
Util.extractText(update)
.filter(campaignName -> campaignName.length() <= MAX_CAMPAIGN_NAME_LENGTH))
.thenApplyAsync(
nameOpt -> {
// 2 - create appropriate message and update chat context
return nameOpt
.map(
campaignName -> {
chatUtil.updateChatContext(
chat,
TRIGGERING_KEYWORD,
2,
Collections.singletonMap(CAMPAIGN_NAME_CHAT_CTX_FIELD, campaignName));
return templateContentGenerator.mergeTemplate(
ctx -> ctx.put(CAMPAIGN_NAME_TEMPLATE_CTX_FIELD, campaignName),
chat.getLocale(),
"template/telegram/createCampaign.2.vm");
})
// We don't update the context here on purpose. If the user wants to try another
// campaign name is free to do so!
.orElseGet(
() ->
templateContentGenerator.mergeTemplate(
ctx -> {},
chat.getLocale(),
"template/telegram/createCampaign.nameNotValid.vm"));
})
.thenApply(
// 3 - send message back
message ->
Optional.of(new SendMessage(chat.getId(), message).parseMode(ParseMode.Markdown)));
}
private CompletableFuture<Optional<BaseRequest<?, ?>>> verifyDescriptionAndCompleteCreation(
TgChat chat, Update update) {
/*
Flow:
1 - Input validation (description length, etc...)
2 - Build message according to validation output
3 - Send message back
4 - ???
5 - Profit
*/
return CompletableFuture.completedFuture(chat)
.thenApply(
ignored ->
Util.extractText(update)
.filter(description -> description.length() <= MAX_CAMPAIGN_DESCRIPTION_LENGTH))
.thenApplyAsync(
descriptionOpt ->
descriptionOpt
.map(
campaignDescription -> {
// We go full beans here because we know that the property will never be
// null at this point - if it is null, then there are bigger problems, and
// we should start questioning our life (and our programming skills
// really)
final String campaignName =
(String)
chat.getChatContext()
.getAdditionalProperties()
.get(CAMPAIGN_NAME_CHAT_CTX_FIELD);
campaignUtil.insertNewCampaign(campaignName, campaignDescription);
chatUtil.updateChatContext(
chat, TRIGGERING_KEYWORD, 3, Collections.emptyMap());
return templateContentGenerator.mergeTemplate(
ctx -> {}, chat.getLocale(), "template/telegram/createCampaign.3.vm");
})
.orElseGet(
() ->
templateContentGenerator.mergeTemplate(
ctx -> {},
chat.getLocale(),
"template/telegram/createCampaign.descriptionNotValid.vm")))
.thenApply(
message ->
Optional.of(new SendMessage(chat.getId(), message).parseMode(ParseMode.Markdown)));
}
}

View File

@ -0,0 +1,17 @@
package com.github.polpetta.mezzotre.telegram.command;
import com.pengrad.telegrambot.model.Message;
import com.pengrad.telegrambot.model.Update;
import com.pengrad.telegrambot.model.User;
import java.util.Optional;
class Util {
static Optional<Long> extractSenderId(Update update) {
return Optional.ofNullable(update).map(Update::message).map(Message::from).map(User::id);
}
static Optional<String> extractText(Update update) {
return Optional.ofNullable(update).map(Update::message).map(Message::text);
}
}

View File

@ -0,0 +1 @@
${i18n.createCampaign.letsStartName}

View File

@ -0,0 +1 @@
${i18n.createCampaign.optionalDescription}

View File

@ -0,0 +1 @@
${i18n.createCampaign.done}

View File

@ -0,0 +1 @@
${i18n.createCampaign.descriptionNotValid}

View File

@ -0,0 +1 @@
${i18n.createCampaign.nameNotValid}

View File

@ -69,6 +69,6 @@ public class UserIntegrationTest {
final User id123 = new QUser().id.eq("id123").findOne(); final User id123 = new QUser().id.eq("id123").findOne();
assertNotNull(id123); assertNotNull(id123);
assertTrue(id123.isActive()); assertTrue(id123.isActive());
assertNull(id123.getTelegramId()); assertNull(id123.getTelegramChat());
} }
} }

View File

@ -0,0 +1,223 @@
package com.github.polpetta.mezzotre.telegram.command;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import com.github.polpetta.mezzotre.i18n.TemplateContentGenerator;
import com.github.polpetta.mezzotre.orm.model.TgChat;
import com.github.polpetta.mezzotre.orm.telegram.CampaignUtil;
import com.github.polpetta.mezzotre.orm.telegram.ChatUtil;
import com.github.polpetta.mezzotre.util.UUIDGenerator;
import com.github.polpetta.types.json.ChatContext;
import com.google.gson.Gson;
import com.pengrad.telegrambot.model.Update;
import com.pengrad.telegrambot.request.BaseRequest;
import java.util.Collections;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.ArgumentCaptor;
@Execution(ExecutionMode.CONCURRENT)
class CreateCampaignTest {
private static Gson gson;
private TemplateContentGenerator fakeTemplateContentGenerator;
private UUIDGenerator fakeUUIDGenerator;
private ChatUtil fakeChatUtil;
private CampaignUtil fakeCampaignUtil;
private CreateCampaign createCampaign;
@BeforeAll
static void beforeAll() {
gson = new Gson();
}
@BeforeEach
void setUp() {
fakeTemplateContentGenerator = mock(TemplateContentGenerator.class);
fakeUUIDGenerator = mock(UUIDGenerator.class);
fakeChatUtil = mock(ChatUtil.class);
fakeCampaignUtil = mock(CampaignUtil.class);
createCampaign =
new CreateCampaign(
fakeTemplateContentGenerator,
Executors.newSingleThreadExecutor(),
fakeUUIDGenerator,
fakeChatUtil,
fakeCampaignUtil);
}
public static Stream<Arguments> getMultipleValidCampaignEntries() {
return Stream.of(
Arguments.of("a simple name", "a simple description"),
Arguments.of("a simple name", "a simple\nmultiline\n description"),
Arguments.of(
"an \uD83E\uDD21 emoji \uD83D\uDCAF name \uD83D\uDD25",
"a very \uD83E\uDDE8 campaign description \uD83E\uDD16"),
Arguments.of("一个简单的名字", "一个简单的描述"));
}
@ParameterizedTest
@MethodSource("getMultipleValidCampaignEntries")
@Timeout(value = 1, unit = TimeUnit.MINUTES)
void shouldGenerateANewCampaign(String name, String description) throws Exception {
final TgChat fakeTgChat = mock(TgChat.class);
final ChatContext initialChatContext = new ChatContext();
initialChatContext.setStep(0); // implicit but better specify just for clarity
when(fakeTgChat.getChatContext()).thenReturn(initialChatContext);
when(fakeTgChat.getLocale()).thenReturn("en-US");
{
when(fakeTemplateContentGenerator.mergeTemplate(
any(), eq("en-US"), eq("template/telegram/createCampaign.0.vm")))
.thenReturn("a string");
final Update firstMessage =
gson.fromJson(
"{\n"
+ "\"update_id\":10000,\n"
+ "\"message\":{\n"
+ " \"date\":1441645532,\n"
+ " \"chat\":{\n"
+ " \"last_name\":\"Test Lastname\",\n"
+ " \"id\":1111111,\n"
+ " \"type\": \"private\",\n"
+ " \"first_name\":\"Test Firstname\",\n"
+ " \"username\":\"Testusername\"\n"
+ " },\n"
+ " \"message_id\":1365,\n"
+ " \"from\":{\n"
+ " \"last_name\":\"Test Lastname\",\n"
+ " \"id\":1111111,\n"
+ " \"first_name\":\"Test Firstname\",\n"
+ " \"username\":\"Testusername\"\n"
+ " },\n"
+ " \"text\":\"/createCampaign\"\n"
+ "}\n"
+ "}",
Update.class);
final CompletableFuture<Optional<BaseRequest<?, ?>>> got =
createCampaign.process(fakeTgChat, firstMessage);
final Optional<BaseRequest<?, ?>> baseRequestOptional = got.get();
final BaseRequest<?, ?> gotResponse = baseRequestOptional.get();
assertEquals("a string", gotResponse.getParameters().get("text"));
verify(fakeChatUtil, times(1))
.updateChatContext(fakeTgChat, "/createCampaign", 1, Collections.emptyMap());
}
{
when(fakeTemplateContentGenerator.mergeTemplate(
any(), eq("en-US"), eq("template/telegram/createCampaign.2.vm")))
.thenReturn("a second string");
final ChatContext chatContext = new ChatContext();
chatContext.setStep(1);
when(fakeTgChat.getChatContext()).thenReturn(chatContext);
final Update secondMessageName =
gson.fromJson(
"{\n"
+ "\"update_id\":10000,\n"
+ "\"message\":{\n"
+ " \"date\":1441645532,\n"
+ " \"chat\":{\n"
+ " \"last_name\":\"Test Lastname\",\n"
+ " \"id\":1111111,\n"
+ " \"type\": \"private\",\n"
+ " \"first_name\":\"Test Firstname\",\n"
+ " \"username\":\"Testusername\"\n"
+ " },\n"
+ " \"message_id\":1365,\n"
+ " \"from\":{\n"
+ " \"last_name\":\"Test Lastname\",\n"
+ " \"id\":1111111,\n"
+ " \"first_name\":\"Test Firstname\",\n"
+ " \"username\":\"Testusername\"\n"
+ " },\n"
+ " \"text\":\""
+ name
+ "\"\n"
+ "}\n"
+ "}",
Update.class);
final CompletableFuture<Optional<BaseRequest<?, ?>>> got =
createCampaign.process(fakeTgChat, secondMessageName);
final Optional<BaseRequest<?, ?>> baseRequestOptional = got.get();
final BaseRequest<?, ?> gotResponse = baseRequestOptional.get();
assertEquals("a second string", gotResponse.getParameters().get("text"));
final ArgumentCaptor<Map<String, Object>> capturedChatCtx =
ArgumentCaptor.forClass(Map.class);
verify(fakeChatUtil, times(1))
.updateChatContext(
eq(fakeTgChat), eq("/createCampaign"), eq(2), capturedChatCtx.capture());
final Map<String, Object> chatCtxValue = capturedChatCtx.getValue();
assertEquals(name, chatCtxValue.get("campaign_name"));
}
{
when(fakeTemplateContentGenerator.mergeTemplate(
any(), eq("en-US"), eq("template/telegram/createCampaign.3.vm")))
.thenReturn("a third string");
final ChatContext chatContext = new ChatContext();
chatContext.setStep(2);
chatContext.setAdditionalProperty("campaign_name", name);
when(fakeTgChat.getChatContext()).thenReturn(chatContext);
final Update thirdMessageDescription =
gson.fromJson(
"{\n"
+ "\"update_id\":10000,\n"
+ "\"message\":{\n"
+ " \"date\":1441645532,\n"
+ " \"chat\":{\n"
+ " \"last_name\":\"Test Lastname\",\n"
+ " \"id\":1111111,\n"
+ " \"type\": \"private\",\n"
+ " \"first_name\":\"Test Firstname\",\n"
+ " \"username\":\"Testusername\"\n"
+ " },\n"
+ " \"message_id\":1365,\n"
+ " \"from\":{\n"
+ " \"last_name\":\"Test Lastname\",\n"
+ " \"id\":1111111,\n"
+ " \"first_name\":\"Test Firstname\",\n"
+ " \"username\":\"Testusername\"\n"
+ " },\n"
+ " \"text\":\""
+ description
+ "\"\n"
+ "}\n"
+ "}",
Update.class);
final CompletableFuture<Optional<BaseRequest<?, ?>>> got =
createCampaign.process(fakeTgChat, thirdMessageDescription);
final Optional<BaseRequest<?, ?>> baseRequestOptional = got.get();
final BaseRequest<?, ?> gotResponse = baseRequestOptional.get();
assertEquals("a third string", gotResponse.getParameters().get("text"));
verify(fakeCampaignUtil, times(1)).insertNewCampaign(name, description);
verify(fakeChatUtil, times(1))
.updateChatContext(fakeTgChat, "/createCampaign", 3, Collections.emptyMap());
}
}
}