Merge pull request #3760 from thingsboard/feature/sms-email-limits

Feature/sms email limits
This commit is contained in:
Igor Kulikov 2020-11-20 19:11:07 +02:00 committed by GitHub
commit 2b868646bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 167 additions and 44 deletions

View File

@ -34,6 +34,7 @@ import org.thingsboard.rule.engine.api.TbRelationTypes;
import org.thingsboard.rule.engine.api.sms.SmsSenderFactory;
import org.thingsboard.server.actors.ActorSystemContext;
import org.thingsboard.server.actors.TbActorRef;
import org.thingsboard.server.common.data.ApiUsageRecordKey;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.Device;

View File

@ -23,6 +23,7 @@ import org.apache.commons.lang3.StringUtils;
import org.jetbrains.annotations.NotNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Lazy;
import org.springframework.core.NestedRuntimeException;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.mail.javamail.MimeMessageHelper;
@ -40,6 +41,8 @@ import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.dao.exception.IncorrectParameterException;
import org.thingsboard.server.dao.settings.AdminSettingsService;
import org.thingsboard.server.queue.usagestats.TbApiUsageClient;
import org.thingsboard.server.service.apiusage.TbApiUsageStateService;
import javax.annotation.PostConstruct;
import javax.mail.MessagingException;
@ -58,18 +61,26 @@ public class DefaultMailService implements MailService {
public static final String UTF_8 = "UTF-8";
public static final int _10K = 10000;
public static final int _1M = 1000000;
@Autowired
private MessageSource messages;
private final MessageSource messages;
private final Configuration freemarkerConfig;
private final AdminSettingsService adminSettingsService;
private final TbApiUsageClient apiUsageClient;
@Lazy
@Autowired
private Configuration freemarkerConfig;
private TbApiUsageStateService apiUsageStateService;
private JavaMailSenderImpl mailSender;
private String mailFrom;
@Autowired
private AdminSettingsService adminSettingsService;
public DefaultMailService(MessageSource messages, Configuration freemarkerConfig, AdminSettingsService adminSettingsService, TbApiUsageClient apiUsageClient) {
this.messages = messages;
this.freemarkerConfig = freemarkerConfig;
this.adminSettingsService = adminSettingsService;
this.apiUsageClient = apiUsageClient;
}
@PostConstruct
private void init() {
@ -148,8 +159,11 @@ public class DefaultMailService implements MailService {
}
@Override
public void sendEmail(String email, String subject, String message) throws ThingsboardException {
sendMail(mailSender, mailFrom, email, subject, message);
public void sendEmail(TenantId tenantId, String email, String subject, String message) throws ThingsboardException {
if (apiUsageStateService.getApiUsageState(tenantId).isEmailSendEnabled()) {
sendMail(mailSender, mailFrom, email, subject, message);
apiUsageClient.report(tenantId, ApiUsageRecordKey.EMAIL_EXEC_COUNT, 1);
}
}
@Override
@ -223,20 +237,23 @@ public class DefaultMailService implements MailService {
}
@Override
public void send(String from, String to, String cc, String bcc, String subject, String body) throws MessagingException {
MimeMessage mailMsg = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(mailMsg, "UTF-8");
helper.setFrom(StringUtils.isBlank(from) ? mailFrom : from);
helper.setTo(to.split("\\s*,\\s*"));
if (!StringUtils.isBlank(cc)) {
helper.setCc(cc.split("\\s*,\\s*"));
public void send(TenantId tenantId, String from, String to, String cc, String bcc, String subject, String body) throws MessagingException {
if (apiUsageStateService.getApiUsageState(tenantId).isEmailSendEnabled()) {
MimeMessage mailMsg = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(mailMsg, "UTF-8");
helper.setFrom(StringUtils.isBlank(from) ? mailFrom : from);
helper.setTo(to.split("\\s*,\\s*"));
if (!StringUtils.isBlank(cc)) {
helper.setCc(cc.split("\\s*,\\s*"));
}
if (!StringUtils.isBlank(bcc)) {
helper.setBcc(bcc.split("\\s*,\\s*"));
}
helper.setSubject(subject);
helper.setText(body);
mailSender.send(helper.getMimeMessage());
apiUsageClient.report(tenantId, ApiUsageRecordKey.EMAIL_EXEC_COUNT, 1);
}
if (!StringUtils.isBlank(bcc)) {
helper.setBcc(bcc.split("\\s*,\\s*"));
}
helper.setSubject(subject);
helper.setText(body);
mailSender.send(helper.getMimeMessage());
}
@Override

View File

@ -17,7 +17,6 @@ package org.thingsboard.server.service.sms;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.NestedRuntimeException;
import org.springframework.stereotype.Service;
import org.thingsboard.rule.engine.api.SmsService;
@ -26,12 +25,15 @@ import org.thingsboard.rule.engine.api.sms.SmsSenderFactory;
import org.thingsboard.rule.engine.api.sms.config.SmsProviderConfiguration;
import org.thingsboard.rule.engine.api.sms.config.TestSmsRequest;
import org.thingsboard.server.common.data.AdminSettings;
import org.thingsboard.server.common.data.ApiUsageRecordKey;
import org.thingsboard.server.common.data.exception.ThingsboardErrorCode;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.dao.settings.AdminSettingsService;
import org.thingsboard.server.dao.util.mapping.JacksonUtil;
import org.thingsboard.server.queue.usagestats.TbApiUsageClient;
import org.thingsboard.server.service.apiusage.TbApiUsageStateService;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
@ -40,14 +42,20 @@ import javax.annotation.PreDestroy;
@Slf4j
public class DefaultSmsService implements SmsService {
@Autowired
private SmsSenderFactory smsSenderFactory;
@Autowired
private AdminSettingsService adminSettingsService;
private final SmsSenderFactory smsSenderFactory;
private final AdminSettingsService adminSettingsService;
private final TbApiUsageStateService apiUsageStateService;
private final TbApiUsageClient apiUsageClient;
private SmsSender smsSender;
public DefaultSmsService(SmsSenderFactory smsSenderFactory, AdminSettingsService adminSettingsService, TbApiUsageStateService apiUsageStateService, TbApiUsageClient apiUsageClient) {
this.smsSenderFactory = smsSenderFactory;
this.adminSettingsService = adminSettingsService;
this.apiUsageStateService = apiUsageStateService;
this.apiUsageClient = apiUsageClient;
}
@PostConstruct
private void init() {
updateSmsConfiguration();
@ -78,18 +86,26 @@ public class DefaultSmsService implements SmsService {
}
}
@Override
public void sendSms(String numberTo, String message) throws ThingsboardException {
private int sendSms(String numberTo, String message) throws ThingsboardException {
if (this.smsSender == null) {
throw new ThingsboardException("Unable to send SMS: no SMS provider configured!", ThingsboardErrorCode.GENERAL);
}
this.sendSms(this.smsSender, numberTo, message);
return this.sendSms(this.smsSender, numberTo, message);
}
@Override
public void sendSms(String[] numbersTo, String message) throws ThingsboardException {
for (String numberTo : numbersTo) {
this.sendSms(numberTo, message);
public void sendSms(TenantId tenantId, String[] numbersTo, String message) throws ThingsboardException {
if (apiUsageStateService.getApiUsageState(tenantId).isSmsSendEnabled()) {
int smsCount = 0;
try {
for (String numberTo : numbersTo) {
smsCount += this.sendSms(numberTo, message);
}
} finally {
if (smsCount > 0) {
apiUsageClient.report(tenantId, ApiUsageRecordKey.SMS_EXEC_COUNT, smsCount);
}
}
}
}

View File

@ -21,7 +21,9 @@ public enum ApiFeature {
TRANSPORT("transportApiState", "Device API"),
DB("dbApiState", "Telemetry persistence"),
RE("ruleEngineApiState", "Rule Engine execution"),
JS("jsExecutionApiState", "JavaScript functions execution");
JS("jsExecutionApiState", "JavaScript functions execution"),
EMAIL("emailApiState", "Email messages"),
SMS("smsApiState", "SMS messages");
@Getter
private final String apiStateKey;

View File

@ -23,11 +23,15 @@ public enum ApiUsageRecordKey {
TRANSPORT_DP_COUNT(ApiFeature.TRANSPORT, "transportDataPointsCount", "transportDataPointsLimit"),
STORAGE_DP_COUNT(ApiFeature.DB, "storageDataPointsCount", "storageDataPointsLimit"),
RE_EXEC_COUNT(ApiFeature.RE, "ruleEngineExecutionCount", "ruleEngineExecutionLimit"),
JS_EXEC_COUNT(ApiFeature.JS, "jsExecutionCount", "jsExecutionLimit");
JS_EXEC_COUNT(ApiFeature.JS, "jsExecutionCount", "jsExecutionLimit"),
EMAIL_EXEC_COUNT(ApiFeature.EMAIL, "emailCount", "emailLimit"),
SMS_EXEC_COUNT(ApiFeature.SMS, "smsCount", "smsLimit");
private static final ApiUsageRecordKey[] JS_RECORD_KEYS = {JS_EXEC_COUNT};
private static final ApiUsageRecordKey[] RE_RECORD_KEYS = {RE_EXEC_COUNT};
private static final ApiUsageRecordKey[] DB_RECORD_KEYS = {STORAGE_DP_COUNT};
private static final ApiUsageRecordKey[] TRANSPORT_RECORD_KEYS = {TRANSPORT_MSG_COUNT, TRANSPORT_DP_COUNT};
private static final ApiUsageRecordKey[] EMAIL_RECORD_KEYS = {EMAIL_EXEC_COUNT};
private static final ApiUsageRecordKey[] SMS_RECORD_KEYS = {SMS_EXEC_COUNT};
@Getter
private final ApiFeature apiFeature;
@ -52,6 +56,10 @@ public enum ApiUsageRecordKey {
return RE_RECORD_KEYS;
case JS:
return JS_RECORD_KEYS;
case EMAIL:
return EMAIL_RECORD_KEYS;
case SMS:
return SMS_RECORD_KEYS;
default:
return new ApiUsageRecordKey[]{};
}

View File

@ -47,6 +47,12 @@ public class ApiUsageState extends BaseData<ApiUsageStateId> implements HasTenan
@Getter
@Setter
private ApiUsageStateValue jsExecState;
@Getter
@Setter
private ApiUsageStateValue emailExecState;
@Getter
@Setter
private ApiUsageStateValue smsExecState;
public ApiUsageState() {
super();
@ -64,6 +70,8 @@ public class ApiUsageState extends BaseData<ApiUsageStateId> implements HasTenan
this.dbStorageState = ur.getDbStorageState();
this.reExecState = ur.getReExecState();
this.jsExecState = ur.getJsExecState();
this.emailExecState = ur.getEmailExecState();
this.smsExecState = ur.getSmsExecState();
}
public boolean isTransportEnabled() {
@ -81,4 +89,12 @@ public class ApiUsageState extends BaseData<ApiUsageStateId> implements HasTenan
public boolean isJsExecEnabled() {
return !ApiUsageStateValue.DISABLED.equals(jsExecState);
}
public boolean isEmailSendEnabled(){
return !ApiUsageStateValue.DISABLED.equals(emailExecState);
}
public boolean isSmsSendEnabled(){
return !ApiUsageStateValue.DISABLED.equals(smsExecState);
}
}

View File

@ -42,6 +42,8 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura
private long maxJSExecutions;
private long maxDPStorageDays;
private int maxRuleNodeExecutionsPerMessage;
private long maxEmails;
private long maxSms;
private double warnThreshold;
@ -58,6 +60,10 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura
return maxREExecutions;
case STORAGE_DP_COUNT:
return maxDPStorageDays;
case EMAIL_EXEC_COUNT:
return maxEmails;
case SMS_EXEC_COUNT:
return maxSms;
}
return 0L;
}

View File

@ -450,6 +450,8 @@ public class ModelConstants {
public static final String API_USAGE_STATE_DB_STORAGE_COLUMN = "db_storage";
public static final String API_USAGE_STATE_RE_EXEC_COLUMN = "re_exec";
public static final String API_USAGE_STATE_JS_EXEC_COLUMN = "js_exec";
public static final String API_USAGE_STATE_EMAIL_EXEC_COLUMN = "email_exec";
public static final String API_USAGE_STATE_SMS_EXEC_COLUMN = "sms_exec";
/**
* Cassandra attributes and timeseries constants.

View File

@ -63,6 +63,12 @@ public class ApiUsageStateEntity extends BaseSqlEntity<ApiUsageState> implements
@Enumerated(EnumType.STRING)
@Column(name = ModelConstants.API_USAGE_STATE_JS_EXEC_COLUMN)
private ApiUsageStateValue jsExecState = ApiUsageStateValue.ENABLED;
@Enumerated(EnumType.STRING)
@Column(name = ModelConstants.API_USAGE_STATE_EMAIL_EXEC_COLUMN)
private ApiUsageStateValue emailExecState = ApiUsageStateValue.ENABLED;
@Enumerated(EnumType.STRING)
@Column(name = ModelConstants.API_USAGE_STATE_SMS_EXEC_COLUMN)
private ApiUsageStateValue smsExecState = ApiUsageStateValue.ENABLED;
public ApiUsageStateEntity() {
}
@ -83,6 +89,8 @@ public class ApiUsageStateEntity extends BaseSqlEntity<ApiUsageState> implements
this.dbStorageState = ur.getDbStorageState();
this.reExecState = ur.getReExecState();
this.jsExecState = ur.getJsExecState();
this.emailExecState = ur.getEmailExecState();
this.smsExecState = ur.getSmsExecState();
}
@Override
@ -99,6 +107,8 @@ public class ApiUsageStateEntity extends BaseSqlEntity<ApiUsageState> implements
ur.setDbStorageState(dbStorageState);
ur.setReExecState(reExecState);
ur.setJsExecState(jsExecState);
ur.setEmailExecState(emailExecState);
ur.setSmsExecState(smsExecState);
return ur;
}

View File

@ -78,6 +78,8 @@ public class ApiUsageStateServiceImpl extends AbstractEntityService implements A
apiUsageState.setReExecState(ApiUsageStateValue.ENABLED);
apiUsageState.setJsExecState(ApiUsageStateValue.ENABLED);
apiUsageState.setDbStorageState(ApiUsageStateValue.ENABLED);
apiUsageState.setSmsExecState(ApiUsageStateValue.ENABLED);
apiUsageState.setEmailExecState(ApiUsageStateValue.ENABLED);
apiUsageStateValidator.validate(apiUsageState, ApiUsageState::getTenantId);
ApiUsageState saved = apiUsageStateDao.save(apiUsageState.getTenantId(), apiUsageState);

View File

@ -416,5 +416,7 @@ CREATE TABLE IF NOT EXISTS api_usage_state (
db_storage varchar(32),
re_exec varchar(32),
js_exec varchar(32),
email_exec varchar(32),
sms_exec varchar(32),
CONSTRAINT api_usage_state_unq_key UNIQUE (tenant_id, entity_id)
);

View File

@ -442,6 +442,8 @@ CREATE TABLE IF NOT EXISTS api_usage_state (
db_storage varchar(32),
re_exec varchar(32),
js_exec varchar(32),
email_exec varchar(32),
sms_exec varchar(32),
CONSTRAINT api_usage_state_unq_key UNIQUE (tenant_id, entity_id)
);

View File

@ -20,6 +20,7 @@ import org.thingsboard.server.common.data.ApiFeature;
import org.thingsboard.server.common.data.ApiUsageStateMailMessage;
import org.thingsboard.server.common.data.ApiUsageStateValue;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.TenantId;
import javax.mail.MessagingException;
@ -27,7 +28,7 @@ public interface MailService {
void updateMailConfiguration();
void sendEmail(String email, String subject, String message) throws ThingsboardException;
void sendEmail(TenantId tenantId, String email, String subject, String message) throws ThingsboardException;
void sendTestMail(JsonNode config, String email) throws ThingsboardException;
@ -39,7 +40,7 @@ public interface MailService {
void sendPasswordWasResetEmail(String loginLink, String email) throws ThingsboardException;
void send(String from, String to, String cc, String bcc, String subject, String body) throws MessagingException;
void send(TenantId tenantId, String from, String to, String cc, String bcc, String subject, String body) throws MessagingException;
void sendAccountLockoutEmail( String lockoutEmail, String email, Integer maxFailedLoginAttempts) throws ThingsboardException;

View File

@ -17,14 +17,13 @@ package org.thingsboard.rule.engine.api;
import org.thingsboard.rule.engine.api.sms.config.TestSmsRequest;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.TenantId;
public interface SmsService {
void updateSmsConfiguration();
void sendSms(String numberTo, String message) throws ThingsboardException;
void sendSms(String[] numbersTo, String message) throws ThingsboardException;;
void sendSms(TenantId tenantId, String[] numbersTo, String message) throws ThingsboardException;;
void sendTestSms(TestSmsRequest testSmsRequest) throws ThingsboardException;

View File

@ -19,6 +19,7 @@ import io.netty.channel.EventLoopGroup;
import org.springframework.data.redis.core.RedisTemplate;
import org.thingsboard.common.util.ListeningExecutor;
import org.thingsboard.rule.engine.api.sms.SmsSenderFactory;
import org.thingsboard.server.common.data.ApiUsageRecordKey;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceProfile;

View File

@ -26,6 +26,7 @@ import org.thingsboard.rule.engine.api.TbNode;
import org.thingsboard.rule.engine.api.TbNodeConfiguration;
import org.thingsboard.rule.engine.api.TbNodeException;
import org.thingsboard.rule.engine.api.util.TbNodeUtils;
import org.thingsboard.server.common.data.ApiUsageRecordKey;
import org.thingsboard.server.common.data.plugin.ComponentType;
import org.thingsboard.server.common.msg.TbMsg;
@ -87,7 +88,7 @@ public class TbSendEmailNode implements TbNode {
private void sendEmail(TbContext ctx, EmailPojo email) throws Exception {
if (this.config.isUseSystemSmtpSettings()) {
ctx.getMailService().send(email.getFrom(), email.getTo(), email.getCc(),
ctx.getMailService().send(ctx.getTenantId(), email.getFrom(), email.getTo(), email.getCc(),
email.getBcc(), email.getSubject(), email.getBody());
} else {
MimeMessage mailMsg = mailSender.createMimeMessage();

View File

@ -23,6 +23,7 @@ import org.thingsboard.rule.engine.api.TbNodeConfiguration;
import org.thingsboard.rule.engine.api.TbNodeException;
import org.thingsboard.rule.engine.api.sms.SmsSender;
import org.thingsboard.rule.engine.api.util.TbNodeUtils;
import org.thingsboard.server.common.data.ApiUsageRecordKey;
import org.thingsboard.server.common.data.plugin.ComponentType;
import org.thingsboard.server.common.msg.TbMsg;
@ -75,7 +76,7 @@ public class TbSendSmsNode implements TbNode {
String message = TbNodeUtils.processPattern(this.config.getSmsMessageTemplate(), msg.getMetaData());
String[] numbersToList = numbersTo.split(",");
if (this.config.isUseSystemSmsSettings()) {
ctx.getSmsService().sendSms(numbersToList, message);
ctx.getSmsService().sendSms(ctx.getTenantId(), numbersToList, message);
} else {
for (String numberTo : numbersToList) {
this.smsSender.sendSms(numberTo, message);

View File

@ -160,6 +160,30 @@
{{ 'tenant-profile.max-rule-node-executions-per-message-range' | translate}}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>tenant-profile.max-emails</mat-label>
<input matInput required min="0" step="1"
formControlName="maxEmails"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxEmails').hasError('required')">
{{ 'tenant-profile.max-emails-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxEmails').hasError('min')">
{{ 'tenant-profile.max-emails-range' | translate}}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>tenant-profile.max-sms</mat-label>
<input matInput required min="0" step="1"
formControlName="maxSms"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxSms').hasError('required')">
{{ 'tenant-profile.max-sms-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxSms').hasError('min')">
{{ 'tenant-profile.max-sms-range' | translate}}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>tenant-profile.transport-tenant-msg-rate-limit</mat-label>
<input matInput formControlName="transportTenantMsgRateLimit">

View File

@ -70,7 +70,9 @@ export class DefaultTenantProfileConfigurationComponent implements ControlValueA
maxREExecutions: [null, [Validators.required, Validators.min(0)]],
maxJSExecutions: [null, [Validators.required, Validators.min(0)]],
maxDPStorageDays: [null, [Validators.required, Validators.min(0)]],
maxRuleNodeExecutionsPerMessage: [null, [Validators.required, Validators.min(0)]]
maxRuleNodeExecutionsPerMessage: [null, [Validators.required, Validators.min(0)]],
maxEmails: [null, [Validators.required, Validators.min(0)]],
maxSms: [null, [Validators.required, Validators.min(0)]]
});
this.defaultTenantProfileConfigurationFormGroup.valueChanges.subscribe(() => {
this.updateModel();

View File

@ -44,6 +44,8 @@ export interface DefaultTenantProfileConfiguration {
maxJSExecutions: number;
maxDPStorageDays: number;
maxRuleNodeExecutionsPerMessage: number;
maxEmails: number;
maxSms: number;
}
export type TenantProfileConfigurations = DefaultTenantProfileConfiguration;
@ -69,7 +71,9 @@ export function createTenantProfileConfiguration(type: TenantProfileType): Tenan
maxREExecutions: 0,
maxJSExecutions: 0,
maxDPStorageDays: 0,
maxRuleNodeExecutionsPerMessage: 0
maxRuleNodeExecutionsPerMessage: 0,
maxEmails: 0,
maxSms: 0
};
configuration = {...defaultConfiguration, type: TenantProfileType.DEFAULT};
break;

View File

@ -2017,7 +2017,13 @@
"max-d-p-storage-days-range": "Minimum number of data points storage days can't be negative",
"max-rule-node-executions-per-message": "Maximum number of rule node executions per message (0 - unlimited)",
"max-rule-node-executions-per-message-required": "Maximum number of rule node executions per message is required.",
"max-rule-node-executions-per-message-range": "Minimum number of rule node executions per message can't be negative"
"max-rule-node-executions-per-message-range": "Minimum number of rule node executions per message can't be negative",
"max-emails": "Maximum number of emails sent (0 - unlimited)",
"max-emails-required": "Maximum number of emails sent is required.",
"max-emails-range": "Maximum number of emails sent can't be negative",
"max-sms": "Maximum number of SMS sent (0 - unlimited)",
"max-sms-required": "Maximum number of SMS sent is required.",
"max-sms-range": "Maximum number of SMS sent can't be negative"
},
"timeinterval": {
"seconds-interval": "{ seconds, plural, 1 {1 second} other {# seconds} }",