parent
f35f715b81
commit
3fc33a5ac9
7 changed files with 1236 additions and 55 deletions
13
lib/models/bot_argument_suggestion.dart
Normal file
13
lib/models/bot_argument_suggestion.dart
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
import "package:nexus/models/room.dart";
|
||||
|
||||
part "bot_argument_suggestion.freezed.dart";
|
||||
|
||||
@Freezed(toJson: false, fromJson: false)
|
||||
class const BotArgumentSuggestion({
|
||||
required final String title,
|
||||
final String? subtitle,
|
||||
final Uri? avatar,
|
||||
required final String text,
|
||||
final Room? room,
|
||||
}) with _$BotArgumentSuggestion;
|
||||
36
lib/models/bot_command.dart
Normal file
36
lib/models/bot_command.dart
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
import "package:nexus/models/content/bot_command_description.dart";
|
||||
|
||||
part "bot_command.freezed.dart";
|
||||
|
||||
@Freezed(toJson: false, fromJson: false)
|
||||
class const BotCommand({
|
||||
required final String sender,
|
||||
required final String botName,
|
||||
final Uri? botAvatar,
|
||||
required final BotCommandDescriptionContent description,
|
||||
}) with _$BotCommand {
|
||||
/// Commands gomuks handles itself, which bots can't override.
|
||||
static const builtIn = ISetConst({
|
||||
"me",
|
||||
"notice",
|
||||
"timestamp",
|
||||
"unencrypted",
|
||||
"rawinputbody",
|
||||
"rainbow",
|
||||
"plain",
|
||||
"html",
|
||||
"htmlmd",
|
||||
});
|
||||
|
||||
String get prefix => "/${description.command}";
|
||||
String get about => [botName, ?description.description].join(" · ");
|
||||
|
||||
bool completes(String text) =>
|
||||
text.startsWith("/") &&
|
||||
(prefix.startsWith(text) || argumentsIn(text) != null);
|
||||
|
||||
String? argumentsIn(String text) =>
|
||||
text.startsWith("$prefix ") ? text.substring(prefix.length + 1) : null;
|
||||
}
|
||||
102
lib/models/bot_command_draft.dart
Normal file
102
lib/models/bot_command_draft.dart
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
import "package:nexus/models/bot_command.dart";
|
||||
import "package:nexus/models/content/bot_command.dart";
|
||||
import "package:nexus/models/content/bot_command_description.dart";
|
||||
|
||||
part "bot_command_draft.freezed.dart";
|
||||
|
||||
@Freezed(toJson: false, fromJson: false)
|
||||
class const BotCommandDraft({
|
||||
required final BotCommand command,
|
||||
required final String roomId,
|
||||
final IMap<String, String> texts = const .empty(),
|
||||
|
||||
// The servers to join rooms picked from suggestions through, by room ID
|
||||
final IMap<String, IList<String>> vias = const .empty(),
|
||||
final ISet<String> options = const .empty(),
|
||||
final String? focused,
|
||||
}) with _$BotCommandDraft {
|
||||
IList<BotParameter> get shown => command.description.parameters
|
||||
.where(
|
||||
(parameter) => !parameter.optional || options.contains(parameter.key),
|
||||
)
|
||||
.toIList();
|
||||
|
||||
IList<BotParameter> get hiddenOptions => command.description.parameters
|
||||
.where(
|
||||
(parameter) => parameter.optional && !options.contains(parameter.key),
|
||||
)
|
||||
.toIList();
|
||||
|
||||
BotParameter? get focusedParameter => command.description.parameters
|
||||
.firstWhereOrNull((parameter) => parameter.key == focused);
|
||||
|
||||
({Object? value, String? error}) parse(BotParameter parameter) =>
|
||||
parameter.schema.parseText(texts[parameter.key] ?? "", roomId: roomId);
|
||||
|
||||
IMap<String, Object> get arguments => .fromEntries(
|
||||
shown
|
||||
.map(
|
||||
(parameter) => switch (parse(parameter).value) {
|
||||
final value? => MapEntry(parameter.key, withVias(value)),
|
||||
null => null,
|
||||
},
|
||||
)
|
||||
.nonNulls,
|
||||
);
|
||||
|
||||
IMap<String, String> get errors => .fromEntries(
|
||||
shown
|
||||
.map(
|
||||
(parameter) => switch (parse(parameter).error) {
|
||||
final error? => MapEntry(parameter.key, error),
|
||||
null => null,
|
||||
},
|
||||
)
|
||||
.nonNulls,
|
||||
);
|
||||
|
||||
IList<BotParameter> get missing => shown
|
||||
.where(
|
||||
(parameter) => !parameter.optional && parse(parameter).value == null,
|
||||
)
|
||||
.toIList();
|
||||
|
||||
String? get problem => errors.isNotEmpty
|
||||
? errors.entries.map((error) => "${error.key}: ${error.value}").join("\n")
|
||||
: missing.isNotEmpty
|
||||
? "Fill in ${missing.map((parameter) => parameter.key).join(", ")} first."
|
||||
: null;
|
||||
|
||||
BotCommandInput get input =>
|
||||
.new(command: command.description.command, arguments: arguments);
|
||||
|
||||
String get body => [
|
||||
command.prefix,
|
||||
...shown
|
||||
.map(
|
||||
(parameter) => switch (texts[parameter.key]
|
||||
?.split(BotParameterSchema.separator)
|
||||
.where((part) => part.isNotEmpty)
|
||||
.join(" ")) {
|
||||
final text? when text.isEmpty => null,
|
||||
final text? when parameter.optional => "--${parameter.key} $text",
|
||||
final text => text,
|
||||
},
|
||||
)
|
||||
.nonNulls,
|
||||
].join(" ");
|
||||
|
||||
Object withVias(Object value) => switch (value) {
|
||||
List() => value.whereType<Object>().map(withVias).toList(),
|
||||
final Map<String, Object> room && {"type": "room_id", "id": final String id}
|
||||
when !room.containsKey("via") =>
|
||||
switch (vias[id]) {
|
||||
final via? => {...room, "via": via.unlock},
|
||||
null => room,
|
||||
},
|
||||
_ => value,
|
||||
};
|
||||
}
|
||||
33
lib/models/content/bot_command.dart
Normal file
33
lib/models/content/bot_command.dart
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
|
||||
part "bot_command.freezed.dart";
|
||||
part "bot_command.g.dart";
|
||||
|
||||
@Freezed(toJson: false, fromJson: false)
|
||||
@JsonSerializable()
|
||||
class const BotCommandMessageContent({
|
||||
required final String body,
|
||||
@JsonKey(name: "org.matrix.msc4391.command")
|
||||
required final BotCommandInput command,
|
||||
final String msgtype = "m.text",
|
||||
}) extends Content with _$BotCommandMessageContent {
|
||||
@override
|
||||
Map<String, Object?> toJson() => _$BotCommandMessageContentToJson(this);
|
||||
|
||||
factory BotCommandMessageContent.fromJson(Map<String, Object?> json) =>
|
||||
_$BotCommandMessageContentFromJson(json);
|
||||
}
|
||||
|
||||
@Freezed(toJson: false, fromJson: false)
|
||||
@JsonSerializable()
|
||||
class const BotCommandInput({
|
||||
required final String command,
|
||||
final IMap<String, Object> arguments = const .empty(),
|
||||
}) with _$BotCommandInput {
|
||||
Map<String, Object?> toJson() => _$BotCommandInputToJson(this);
|
||||
|
||||
factory BotCommandInput.fromJson(Map<String, Object?> json) =>
|
||||
_$BotCommandInputFromJson(json);
|
||||
}
|
||||
229
lib/models/content/bot_command_description.dart
Normal file
229
lib/models/content/bot_command_description.dart
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
import "package:nexus/helpers/extensions/get_link_info.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
|
||||
part "bot_command_description.freezed.dart";
|
||||
part "bot_command_description.g.dart";
|
||||
|
||||
@Freezed(toJson: false, fromJson: false)
|
||||
@JsonSerializable()
|
||||
class const BotCommandDescriptionContent({
|
||||
required final String command,
|
||||
final IList<BotParameter> parameters = const .empty(),
|
||||
@JsonKey(fromJson: BotCommandDescriptionContent.plainText)
|
||||
final String? description,
|
||||
}) extends Content with _$BotCommandDescriptionContent {
|
||||
// The plain text from an extensible events m.text block
|
||||
static String? plainText(Map<String, dynamic>? json) =>
|
||||
switch (json?["m.text"]) {
|
||||
final List representations =>
|
||||
representations
|
||||
.map(
|
||||
(representation) => switch (representation) {
|
||||
{"mimetype": final String mimetype}
|
||||
when mimetype != "text/plain" =>
|
||||
null,
|
||||
{"body": final String body} => body,
|
||||
_ => null,
|
||||
},
|
||||
)
|
||||
.nonNulls
|
||||
.firstOrNull,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
bool get isValid =>
|
||||
command.trim().isNotEmpty &&
|
||||
parameters.map((parameter) => parameter.key).toISet().length ==
|
||||
parameters.length &&
|
||||
parameters.every((parameter) => parameter.schema.isValid());
|
||||
|
||||
@override
|
||||
Map<String, Object?> toJson() => _$BotCommandDescriptionContentToJson(this);
|
||||
|
||||
factory BotCommandDescriptionContent.fromJson(Map<String, Object?> json) =>
|
||||
_$BotCommandDescriptionContentFromJson(json);
|
||||
}
|
||||
|
||||
@Freezed(toJson: false, fromJson: false)
|
||||
@JsonSerializable()
|
||||
class const BotParameter({
|
||||
required final String key,
|
||||
required final BotParameterSchema schema,
|
||||
final bool optional = false,
|
||||
@JsonKey(fromJson: BotCommandDescriptionContent.plainText)
|
||||
final String? description,
|
||||
}) with _$BotParameter {
|
||||
Map<String, Object?> toJson() => _$BotParameterToJson(this);
|
||||
|
||||
factory BotParameter.fromJson(Map<String, Object?> json) =>
|
||||
_$BotParameterFromJson(json);
|
||||
}
|
||||
|
||||
@Freezed(unionKey: "schema_type")
|
||||
sealed class BotParameterSchema with _$BotParameterSchema {
|
||||
const BotParameterSchema._();
|
||||
|
||||
static final separator = RegExp(r"[\s,]+");
|
||||
|
||||
const factory BotParameterSchema.primitive({required BotPrimitiveType type}) =
|
||||
PrimitiveSchema;
|
||||
|
||||
const factory BotParameterSchema.literal({required Object value}) =
|
||||
LiteralSchema;
|
||||
|
||||
const factory BotParameterSchema.union({
|
||||
required IList<BotParameterSchema> variants,
|
||||
}) = UnionSchema;
|
||||
|
||||
const factory BotParameterSchema.array({required BotParameterSchema items}) =
|
||||
ArraySchema;
|
||||
|
||||
bool isValid({BotParameterSchema? parent}) => switch (this) {
|
||||
PrimitiveSchema() => true,
|
||||
LiteralSchema(:final value) =>
|
||||
value is String || value is int || value is bool,
|
||||
UnionSchema(:final variants) =>
|
||||
(parent == null || parent is ArraySchema) &&
|
||||
variants.isNotEmpty &&
|
||||
variants.every(
|
||||
(variant) =>
|
||||
variant is! UnionSchema && variant.isValid(parent: this),
|
||||
),
|
||||
ArraySchema(:final items) => parent == null && items.isValid(parent: this),
|
||||
};
|
||||
|
||||
String get label => switch (this) {
|
||||
PrimitiveSchema(:final type) => type.label,
|
||||
LiteralSchema(:final value) => "$value",
|
||||
UnionSchema(:final variants) =>
|
||||
variants.map((variant) => variant.label).join(" | "),
|
||||
ArraySchema(:final items) => "${items.label}...",
|
||||
};
|
||||
|
||||
String get expected => switch (this) {
|
||||
PrimitiveSchema(:final type) => type.expected,
|
||||
LiteralSchema(:final value) => '"$value"',
|
||||
UnionSchema(:final variants) =>
|
||||
variants.map((variant) => variant.expected).join(" or "),
|
||||
ArraySchema(:final items) => items.expected,
|
||||
};
|
||||
|
||||
bool allows(BotPrimitiveType type) => switch (this) {
|
||||
PrimitiveSchema(type: final allowed) => allowed == type,
|
||||
LiteralSchema() => false,
|
||||
UnionSchema(:final variants) => variants.any(
|
||||
(variant) => variant.allows(type),
|
||||
),
|
||||
ArraySchema(:final items) => items.allows(type),
|
||||
};
|
||||
|
||||
IList<Object> get literals => switch (this) {
|
||||
PrimitiveSchema() => const .empty(),
|
||||
LiteralSchema(:final value) => [value].lock,
|
||||
UnionSchema(:final variants) =>
|
||||
variants.expand((variant) => variant.literals).toIList(),
|
||||
ArraySchema(:final items) => items.literals,
|
||||
};
|
||||
|
||||
Object? parse(String text, {required String roomId}) => switch (this) {
|
||||
PrimitiveSchema(:final type) => type.parse(text, roomId: roomId),
|
||||
LiteralSchema(:final value) => "$value" == text ? value : null,
|
||||
UnionSchema(:final variants) =>
|
||||
variants
|
||||
.map((variant) => variant.parse(text, roomId: roomId))
|
||||
.nonNulls
|
||||
.firstOrNull,
|
||||
ArraySchema(:final items) => items.parse(text, roomId: roomId),
|
||||
};
|
||||
|
||||
({Object? value, String? error}) parseText(
|
||||
String text, {
|
||||
required String roomId,
|
||||
}) {
|
||||
final parts = (this is ArraySchema ? text.split(separator) : [text.trim()])
|
||||
.where((part) => part.isNotEmpty)
|
||||
.map((part) => (part, parse(part, roomId: roomId)))
|
||||
.toIList();
|
||||
|
||||
return switch (parts.firstWhereOrNull((part) => part.$2 == null)) {
|
||||
(final part, _) => (value: null, error: '"$part" isn\'t $expected.'),
|
||||
null when parts.isEmpty => (value: null, error: null),
|
||||
null when this is ArraySchema => (
|
||||
value: parts.map((part) => part.$2).nonNulls.toList(),
|
||||
error: null,
|
||||
),
|
||||
null => (value: parts.first.$2, error: null),
|
||||
};
|
||||
}
|
||||
|
||||
factory BotParameterSchema.fromJson(Map<String, Object?> json) =>
|
||||
_$BotParameterSchemaFromJson(json);
|
||||
}
|
||||
|
||||
enum BotPrimitiveType(final String label, final String expected) {
|
||||
@JsonValue("string")
|
||||
string("text", "text"),
|
||||
@JsonValue("integer")
|
||||
integer("number", "a whole number"),
|
||||
@JsonValue("boolean")
|
||||
boolean("yes/no", "yes or no"),
|
||||
@JsonValue("user_id")
|
||||
userId("user", "a user ID like @alice:example.org"),
|
||||
@JsonValue("server_name")
|
||||
serverName("server", "a server name like example.org"),
|
||||
@JsonValue("room_alias")
|
||||
roomAlias("room alias", "a room alias like #room:example.org"),
|
||||
@JsonValue("room_id")
|
||||
roomId("room", "a room ID or link like !abc:example.org"),
|
||||
@JsonValue("event_id")
|
||||
eventId("event", r"a message link or event ID like $abc");
|
||||
|
||||
static final serverNamePattern = RegExp(
|
||||
r"^(\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9.-]+)(:\d{1,5})?$",
|
||||
);
|
||||
|
||||
Object? parse(String text, {required String roomId}) {
|
||||
final (:identifier, :eventId, :via) =
|
||||
text.linkInfo ??
|
||||
(identifier: text, eventId: null, via: const IList<String>.empty());
|
||||
|
||||
return switch (this) {
|
||||
.string => text,
|
||||
// In the range canonical JSON allows
|
||||
.integer => switch (int.tryParse(text, radix: 10)) {
|
||||
final value? when value.abs() <= 9007199254740991 => value,
|
||||
_ => null,
|
||||
},
|
||||
.boolean => switch (text.toLowerCase()) {
|
||||
"true" || "yes" => true,
|
||||
"false" || "no" => false,
|
||||
_ => null,
|
||||
},
|
||||
.userId when identifier.startsWith("@") && identifier.contains(":") =>
|
||||
identifier,
|
||||
.roomAlias when identifier.startsWith("#") && identifier.contains(":") =>
|
||||
identifier,
|
||||
.serverName when serverNamePattern.hasMatch(text) => text,
|
||||
.roomId when identifier.startsWith("!") && eventId == null => {
|
||||
"type": "room_id",
|
||||
"id": identifier,
|
||||
if (via.isNotEmpty) "via": via.unlock,
|
||||
},
|
||||
.eventId when identifier.startsWith("!") && eventId != null => {
|
||||
"type": "event_id",
|
||||
"id": identifier,
|
||||
"event_id": eventId,
|
||||
if (via.isNotEmpty) "via": via.unlock,
|
||||
},
|
||||
.eventId when identifier.startsWith(r"$") => {
|
||||
"type": "event_id",
|
||||
"id": roomId,
|
||||
"event_id": identifier,
|
||||
},
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:nexus/models/content/avatar.dart";
|
||||
import "package:nexus/models/content/bot_command_description.dart";
|
||||
import "package:nexus/models/content/canonical_alias.dart";
|
||||
import "package:nexus/models/content/create.dart";
|
||||
import "package:nexus/models/content/encryption.dart";
|
||||
|
|
@ -75,5 +76,9 @@ enum EventType(
|
|||
PollResponseContent.fromJson,
|
||||
),
|
||||
pollEnd("org.matrix.msc3381.poll.end", PollEndContent.fromJson),
|
||||
botCommandDescription(
|
||||
"org.matrix.msc4391.command_description",
|
||||
BotCommandDescriptionContent.fromJson,
|
||||
),
|
||||
message("m.room.message", MessageContent.fromJson),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
import "package:nexus/models/content/bot_command.dart";
|
||||
import "package:nexus/models/info/audio.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/info/file.dart";
|
||||
|
|
@ -24,6 +25,7 @@ sealed class MessageContent extends Content with _$MessageContent {
|
|||
@JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue)
|
||||
MessageFormat? format,
|
||||
String? formattedBody,
|
||||
@JsonKey(name: "org.matrix.msc4391.command") BotCommandInput? botCommand,
|
||||
}) = TextMessageContent;
|
||||
|
||||
@FreezedUnionValue("m.notice")
|
||||
|
|
|
|||
Loading…
Reference in a new issue