diff --git a/assets/templates/mattermost_build_test.yaml b/assets/templates/mattermost_build_test.yaml new file mode 100644 index 0000000..cc6a55e --- /dev/null +++ b/assets/templates/mattermost_build_test.yaml @@ -0,0 +1,21 @@ +--- +property: + workspace: . + +pipeline: + id: main + workflow: + - exe: mattermost-build + +commands: + - command: MattermostBuild + id: mattermost-build + param: + url: https://mattermost.toki-labs.com/api/v4/posts + token: 3zihstia4frodxgea7i91agbye + channelId: n78a1wrdwjydzdoat6gci8cphe + appName: oto_cli + type: ios + color: ED1458 + version: 1.0.0-test + downloadURL: https://example.com/download/oto_cli.ipa diff --git a/assets/templates/mattermost_test.yaml b/assets/templates/mattermost_test.yaml new file mode 100644 index 0000000..92fff26 --- /dev/null +++ b/assets/templates/mattermost_test.yaml @@ -0,0 +1,22 @@ +--- +property: + workspace: . + +pipeline: + id: main + workflow: + - exe: mattermost + +commands: + - command: Mattermost + id: mattermost + param: + url: https://mattermost.toki-labs.com/api/v4/posts + token: 3zihstia4frodxgea7i91agbye + message: + channel_id: n78a1wrdwjydzdoat6gci8cphe + message: 봇 테스트 메시지입니다. + props: + attachments: + - pretext: Mattermost Bot Test + text: 정상적으로 전송되면 이 메시지가 채널에 표시됩니다. diff --git a/assets/templates/mattermost_test_json.yaml b/assets/templates/mattermost_test_json.yaml new file mode 100644 index 0000000..b9b9ab3 --- /dev/null +++ b/assets/templates/mattermost_test_json.yaml @@ -0,0 +1,28 @@ +--- +property: + workspace: . + +pipeline: + id: main + workflow: + - exe: mattermost + +commands: + - command: Mattermost + id: mattermost + param: + url: https://mattermost.toki-labs.com/api/v4/posts + token: 3zihstia4frodxgea7i91agbye + messageJson: | + { + "channel_id": "n78a1wrdwjydzdoat6gci8cphe", + "message": "봇 테스트 메시지입니다. (messageJson)", + "props": { + "attachments": [ + { + "pretext": "Mattermost Bot Test", + "text": "정상적으로 전송되면 이 메시지가 채널에 표시됩니다. (messageJson)" + } + ] + } + } diff --git a/assets/templates/mattermost_test_property.yaml b/assets/templates/mattermost_test_property.yaml new file mode 100644 index 0000000..2b8a1ea --- /dev/null +++ b/assets/templates/mattermost_test_property.yaml @@ -0,0 +1,29 @@ +--- +property: + workspace: . + +pipeline: + id: main + workflow: + - exe: set-payload + - exe: mattermost + +commands: + - command: SetValue + id: set-payload + param: + values-object: + mattermostPayload: + channel_id: n78a1wrdwjydzdoat6gci8cphe + message: 봇 테스트 메시지입니다. (property) + props: + attachments: + - pretext: Mattermost Bot Test + text: 정상적으로 전송되면 이 메시지가 채널에 표시됩니다. (property) + + - command: Mattermost + id: mattermost + param: + url: https://mattermost.toki-labs.com/api/v4/posts + token: 3zihstia4frodxgea7i91agbye + property: diff --git a/lib/oto/commands/command.dart b/lib/oto/commands/command.dart index 8c1f3d1..50457a1 100644 --- a/lib/oto/commands/command.dart +++ b/lib/oto/commands/command.dart @@ -29,6 +29,7 @@ import 'package:oto_cli/oto/commands/jira/jira.dart'; import 'package:oto_cli/oto/commands/process/process.dart'; import 'package:oto_cli/oto/commands/proto/protobuf.dart'; import 'package:oto_cli/oto/commands/util/json.dart'; +import 'package:oto_cli/oto/commands/util/mattermost.dart'; import 'package:oto_cli/oto/commands/util/print.dart'; import 'package:oto_cli/oto/commands/util/set_value.dart'; import 'package:oto_cli/oto/commands/util/smb_authorize.dart'; @@ -83,6 +84,8 @@ enum CommandType { Slack, SlackFile, SlackBuild, + Mattermost, + MattermostBuild, StringSub, StringReplace, StringReplacePattern, @@ -161,6 +164,8 @@ abstract class Command { CommandType.Slack: () => Slack(), CommandType.SlackFile: () => SlackFile(), CommandType.SlackBuild: () => SlackBuild(), + CommandType.Mattermost: () => Mattermost(), + CommandType.MattermostBuild: () => MattermostBuild(), CommandType.StringSub: () => StringSub(), CommandType.StringReplace: () => StringReplace(), CommandType.StringReplacePattern: () => StringReplacePattern(), diff --git a/lib/oto/commands/util/mattermost.dart b/lib/oto/commands/util/mattermost.dart new file mode 100644 index 0000000..02fec06 --- /dev/null +++ b/lib/oto/commands/util/mattermost.dart @@ -0,0 +1,97 @@ +// ignore_for_file: avoid_init_to_null + +import 'dart:async'; + +import 'package:color/color.dart'; +import 'package:dart_framework/utils/system_util.dart'; +import 'package:oto_cli/oto/commands/command.dart'; +import 'package:oto_cli/oto/data/command_data.dart'; +import 'package:oto_cli/oto/utils/mattermost/mattermost_data.dart'; +import 'package:oto_cli/oto/utils/mattermost/mattermost_sender.dart'; + +class Mattermost extends Command { + @override + Future execute(DataCommand command) async { + final data = DataMattermostSender.fromJson(getParam(command)); + final sender = MattermostSender(data.token, data.url); + + if (data.property != null) { + final value = data.property; + if (value is DataMattermost) { + await sender.sendData(value, defaultChannelId: data.channelId); + } else if (value is Map) { + await sender.sendMap( + DataMattermost.fromJson(Map.from(value)) + .toPostJson(defaultChannelId: data.channelId), + ); + } else { + throw Exception('The property must be DataMattermost or Map.'); + } + } else if (data.message != null) { + final payload = Map.from(data.message!); + await sender.sendMap( + _replaceMentionsInMap(payload, data.idMap).cast()); + } else if (data.messageJson != null) { + await sender.send(getName2ID(data.messageJson!, data.idMap)); + } else { + throw Exception('property or message field should not be null'); + } + + return simpleFuture; + } + + String getName2ID(String message, Map? idMap) { + if (idMap == null) { + return message; + } + + for (final entry in idMap.entries) { + message = message.replaceAll('@${entry.key}', '@${entry.value}'); + } + return message; + } + + dynamic _replaceMentionsInMap(dynamic value, Map? idMap) { + if (idMap == null) { + return value; + } + if (value is String) { + var replaced = value; + for (final entry in idMap.entries) { + replaced = replaced.replaceAll('@${entry.key}', '@${entry.value}'); + } + return replaced; + } + if (value is List) { + return value.map((item) => _replaceMentionsInMap(item, idMap)).toList(); + } + if (value is Map) { + return value.map( + (key, item) => MapEntry(key, _replaceMentionsInMap(item, idMap))); + } + return value; + } +} + +class MattermostBuild extends Command { + @override + Future execute(DataCommand command) async { + final data = DataMattermostBuild.fromJson(getParam(command)); + final sender = MattermostSender(data.token, data.url); + final channelId = data.channelId; + if (channelId == null || channelId.isEmpty) { + throw Exception('channelId is required for MattermostBuild.'); + } + + await sender.sendBuild( + data.appName, + data.type, + Color.hex(data.color), + data.version, + data.downloadURL, + channelId: channelId, + ); + + return complete(0, command); + } +} diff --git a/lib/oto/core/defined_data.dart b/lib/oto/core/defined_data.dart index 3a5fa14..5bd6109 100644 --- a/lib/oto/core/defined_data.dart +++ b/lib/oto/core/defined_data.dart @@ -149,6 +149,18 @@ commands: # downloadURL: http://toki-labs.com/build/tplayer/android/tplayer_arm64_.apk # channel: release +#- command: Mattermost +# param: +# url: https://mattermost.example.com/api/v4/posts +# token: bot-token +# channelId: n78a1wrdwjydzdoat6gci8cphe +# message: +# message: 봇 테스트 메시지입니다. +# props: +# attachments: +# - pretext: Mattermost Bot Test +# text: 정상적으로 전송되면 이 메시지가 채널에 표시됩니다. + '''; } else if (Platform.isWindows) { yaml = ''' @@ -269,6 +281,18 @@ commands: # downloadURL: http://toki-labs.com/build/tplayer/android/tplayer_arm64_.apk # channel: release +#- command: Mattermost +# param: +# url: https://mattermost.example.com/api/v4/posts +# token: bot-token +# channelId: n78a1wrdwjydzdoat6gci8cphe +# message: +# message: 봇 테스트 메시지입니다. +# props: +# attachments: +# - pretext: Mattermost Bot Test +# text: 정상적으로 전송되면 이 메시지가 채널에 표시됩니다. + '''; } else if (Platform.isLinux) {} diff --git a/lib/oto/data/command_data.dart b/lib/oto/data/command_data.dart index ccb5ff9..592d89e 100644 --- a/lib/oto/data/command_data.dart +++ b/lib/oto/data/command_data.dart @@ -7,6 +7,7 @@ import 'package:oto_cli/oto/commands/build/build_flutter.dart'; import 'package:oto_cli/oto/commands/build/build_msbuild.dart'; import 'package:oto_cli/oto/commands/command.dart'; import 'package:oto_cli/oto/data/pipeline_data.dart'; +import 'package:oto_cli/oto/utils/mattermost/mattermost_sender.dart'; part 'command_data.g.dart'; /// ***************************** Base Data ****************************/// @@ -624,6 +625,42 @@ class DataSlackBuild extends DataSlackSender { Map toJson() => _$DataSlackBuildToJson(this); } +//Mattermost +@JsonSerializable() +class DataMattermostSender extends DataParam { + DataMattermostSender(); + + late String token; + late String url; + late String? channelId; + late dynamic property; + late Map? message; + late String? messageJson; + late Map? idMap; + + factory DataMattermostSender.fromJson(Map json) => + _$DataMattermostSenderFromJson(json); + @override + Map toJson() => _$DataMattermostSenderToJson(this); +} + +//Mattermost Build +@JsonSerializable() +class DataMattermostBuild extends DataMattermostSender { + DataMattermostBuild(); + + late String appName; + late MattermostBuildType type; + late String color; + late String version; + late String downloadURL; + + factory DataMattermostBuild.fromJson(Map json) => + _$DataMattermostBuildFromJson(json); + @override + Map toJson() => _$DataMattermostBuildToJson(this); +} + //Jira @JsonSerializable() class DataJira extends DataParam { diff --git a/lib/oto/data/command_data.g.dart b/lib/oto/data/command_data.g.dart index a9de1e6..67919d9 100644 --- a/lib/oto/data/command_data.g.dart +++ b/lib/oto/data/command_data.g.dart @@ -88,6 +88,8 @@ const _$CommandTypeEnumMap = { CommandType.Slack: 'Slack', CommandType.SlackFile: 'SlackFile', CommandType.SlackBuild: 'SlackBuild', + CommandType.Mattermost: 'Mattermost', + CommandType.MattermostBuild: 'MattermostBuild', CommandType.StringSub: 'StringSub', CommandType.StringReplace: 'StringReplace', CommandType.StringReplacePattern: 'StringReplacePattern', @@ -1117,6 +1119,97 @@ const _$SlackBuildTypeEnumMap = { SlackBuildType.web: 'web', }; +DataMattermostSender _$DataMattermostSenderFromJson( + Map json) => + DataMattermostSender() + ..workspace = json['workspace'] as String? + ..passExitCodes = (json['passExitCodes'] as List?) + ?.map((e) => (e as num).toInt()) + .toList() + ..setResult = json['setResult'] as String? + ..setExitCode = json['setExitCode'] as String? + ..showPrint = json['showPrint'] as bool? + ..token = json['token'] as String + ..url = json['url'] as String + ..channelId = json['channelId'] as String? + ..property = json['property'] + ..message = json['message'] as Map? + ..messageJson = json['messageJson'] as String? + ..idMap = (json['idMap'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ); + +Map _$DataMattermostSenderToJson( + DataMattermostSender instance) => + { + 'workspace': instance.workspace, + 'passExitCodes': instance.passExitCodes, + 'setResult': instance.setResult, + 'setExitCode': instance.setExitCode, + 'showPrint': instance.showPrint, + 'token': instance.token, + 'url': instance.url, + 'channelId': instance.channelId, + 'property': instance.property, + 'message': instance.message, + 'messageJson': instance.messageJson, + 'idMap': instance.idMap, + }; + +DataMattermostBuild _$DataMattermostBuildFromJson(Map json) => + DataMattermostBuild() + ..workspace = json['workspace'] as String? + ..passExitCodes = (json['passExitCodes'] as List?) + ?.map((e) => (e as num).toInt()) + .toList() + ..setResult = json['setResult'] as String? + ..setExitCode = json['setExitCode'] as String? + ..showPrint = json['showPrint'] as bool? + ..token = json['token'] as String + ..url = json['url'] as String + ..channelId = json['channelId'] as String? + ..property = json['property'] + ..message = json['message'] as Map? + ..messageJson = json['messageJson'] as String? + ..idMap = (json['idMap'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ) + ..appName = json['appName'] as String + ..type = $enumDecode(_$MattermostBuildTypeEnumMap, json['type']) + ..color = json['color'] as String + ..version = json['version'] as String + ..downloadURL = json['downloadURL'] as String; + +Map _$DataMattermostBuildToJson( + DataMattermostBuild instance) => + { + 'workspace': instance.workspace, + 'passExitCodes': instance.passExitCodes, + 'setResult': instance.setResult, + 'setExitCode': instance.setExitCode, + 'showPrint': instance.showPrint, + 'token': instance.token, + 'url': instance.url, + 'channelId': instance.channelId, + 'property': instance.property, + 'message': instance.message, + 'messageJson': instance.messageJson, + 'idMap': instance.idMap, + 'appName': instance.appName, + 'type': _$MattermostBuildTypeEnumMap[instance.type]!, + 'color': instance.color, + 'version': instance.version, + 'downloadURL': instance.downloadURL, + }; + +const _$MattermostBuildTypeEnumMap = { + MattermostBuildType.android: 'android', + MattermostBuildType.ios: 'ios', + MattermostBuildType.windows: 'windows', + MattermostBuildType.mac: 'mac', + MattermostBuildType.web: 'web', +}; + DataJira _$DataJiraFromJson(Map json) => DataJira() ..workspace = json['workspace'] as String? ..passExitCodes = (json['passExitCodes'] as List?) diff --git a/lib/oto/utils/mattermost/mattermost_data.dart b/lib/oto/utils/mattermost/mattermost_data.dart new file mode 100644 index 0000000..37c6fc7 --- /dev/null +++ b/lib/oto/utils/mattermost/mattermost_data.dart @@ -0,0 +1,246 @@ +// ignore_for_file: non_constant_identifier_names + +import 'dart:convert'; + +class DataMattermost { + String? channel_id; + String? channel; + String? message; + String? text; + String? username; + String? icon_url; + String? icon_emoji; + String? type; + Map? props; + List? attachments; + + String get json { + return jsonEncode(toPostJson()); + } + + DataMattermost(); + + factory DataMattermost.fromJson(Map json) { + final data = DataMattermost(); + data.channel_id = json['channel_id'] as String?; + data.channel = json['channel'] as String?; + data.message = json['message'] as String?; + data.text = json['text'] as String?; + data.username = json['username'] as String?; + data.icon_url = json['icon_url'] as String?; + data.icon_emoji = json['icon_emoji'] as String?; + data.type = json['type'] as String?; + if (json['props'] is Map) { + data.props = + Map.from(json['props'] as Map); + } else if (json['props'] is Map) { + data.props = Map.from(json['props'] as Map); + } + if (json['attachments'] is List) { + data.attachments = (json['attachments'] as List) + .map((item) => MattermostAttachment.fromJson( + Map.from(item as Map))) + .toList(); + } + return data; + } + + Map toJson() { + return removeEmptyKeys({ + 'channel_id': channel_id, + 'channel': channel, + 'message': message, + 'text': text, + 'username': username, + 'icon_url': icon_url, + 'icon_emoji': icon_emoji, + 'type': type, + 'props': props, + 'attachments': attachments?.map((item) => item.toJson()).toList(), + }); + } + + Map toPostJson({String? defaultChannelId}) { + return normalizeMattermostPost(toJson(), + defaultChannelId: defaultChannelId); + } +} + +class MattermostAttachment { + String? fallback; + String? color; + String? pretext; + String? text; + String? author_name; + String? author_link; + String? author_icon; + String? title; + String? title_link; + String? image_url; + String? thumb_url; + String? footer; + String? footer_icon; + List? fields; + List>? actions; + + MattermostAttachment(); + + factory MattermostAttachment.fromJson(Map json) { + final data = MattermostAttachment(); + data.fallback = json['fallback'] as String?; + data.color = json['color'] as String?; + data.pretext = json['pretext'] as String?; + data.text = json['text'] as String?; + data.author_name = json['author_name'] as String?; + data.author_link = json['author_link'] as String?; + data.author_icon = json['author_icon'] as String?; + data.title = json['title'] as String?; + data.title_link = json['title_link'] as String?; + data.image_url = json['image_url'] as String?; + data.thumb_url = json['thumb_url'] as String?; + data.footer = json['footer'] as String?; + data.footer_icon = json['footer_icon'] as String?; + if (json['fields'] is List) { + data.fields = (json['fields'] as List) + .map((item) => + MattermostField.fromJson(Map.from(item as Map))) + .toList(); + } + if (json['actions'] is List) { + data.actions = (json['actions'] as List) + .map((item) => Map.from(item as Map)) + .toList(); + } + return data; + } + + Map toJson() { + return removeEmptyKeys({ + 'fallback': fallback, + 'color': color, + 'pretext': pretext, + 'text': text, + 'author_name': author_name, + 'author_link': author_link, + 'author_icon': author_icon, + 'title': title, + 'title_link': title_link, + 'image_url': image_url, + 'thumb_url': thumb_url, + 'footer': footer, + 'footer_icon': footer_icon, + 'fields': fields?.map((item) => item.toJson()).toList(), + 'actions': actions, + }); + } +} + +class MattermostField { + String? title; + String? value; + bool short = false; + + MattermostField(); + MattermostField.withParam(this.title, this.value, {this.short = false}); + + factory MattermostField.fromJson(Map json) { + final data = MattermostField(); + data.title = json['title'] as String?; + data.value = json['value'] as String?; + data.short = json['short'] as bool? ?? false; + return data; + } + + Map toJson() { + return removeEmptyKeys({ + 'title': title, + 'value': value, + 'short': short, + }); + } +} + +Map normalizeMattermostPost(Map payload, + {String? defaultChannelId}) { + final map = Map.from(payload); + final props = map['props'] is Map + ? Map.from(map['props'] as Map) + : map['props'] is Map + ? Map.from(map['props'] as Map) + : {}; + + if ((map['channel_id'] == null || '${map['channel_id']}'.isEmpty)) { + final channel = map.remove('channel'); + if (channel != null && '$channel'.isNotEmpty) { + map['channel_id'] = channel; + } else if (defaultChannelId != null && defaultChannelId.isNotEmpty) { + map['channel_id'] = defaultChannelId; + } + } else { + map.remove('channel'); + } + + if ((map['message'] == null || '${map['message']}'.isEmpty) && + map['text'] != null) { + map['message'] = map.remove('text'); + } else { + map.remove('text'); + } + + _moveValueToProps(map, props, 'attachments', 'attachments'); + _moveValueToProps(map, props, 'username', 'override_username'); + _moveValueToProps(map, props, 'icon_url', 'override_icon_url'); + _moveValueToProps(map, props, 'icon_emoji', 'override_icon_emoji'); + + if (props.isNotEmpty) { + map['props'] = props; + } else { + map.remove('props'); + } + + return removeEmptyKeys(map); +} + +void _moveValueToProps( + Map source, + Map props, + String sourceKey, + String targetKey, +) { + if (props[targetKey] != null) { + source.remove(sourceKey); + return; + } + + final value = source.remove(sourceKey); + if (value != null) { + props[targetKey] = value; + } +} + +Map removeEmptyKeys(Map map) { + final emptyKeys = []; + for (final item in map.entries) { + final value = item.value; + if (value == null) { + emptyKeys.add(item.key); + continue; + } + if (value is String && value.isEmpty) { + emptyKeys.add(item.key); + continue; + } + if (value is List && value.isEmpty) { + emptyKeys.add(item.key); + continue; + } + if (value is Map && value.isEmpty) { + emptyKeys.add(item.key); + } + } + + for (final key in emptyKeys) { + map.remove(key); + } + return map; +} diff --git a/lib/oto/utils/mattermost/mattermost_sender.dart b/lib/oto/utils/mattermost/mattermost_sender.dart new file mode 100644 index 0000000..fa8c334 --- /dev/null +++ b/lib/oto/utils/mattermost/mattermost_sender.dart @@ -0,0 +1,113 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:color/color.dart'; +import 'package:dart_framework/utils/string_util.dart'; +import 'package:dart_framework/utils/system_util.dart'; +import 'package:http/http.dart' as http; +import 'package:oto_cli/oto/utils/mattermost/mattermost_data.dart'; + +enum MattermostBuildType { android, ios, windows, mac, web } + +class MattermostSender { + final String token; + final String url; + + MattermostSender(this.token, this.url); + + Future sendData(DataMattermost message, + {String? defaultChannelId, + Function(String)? completeListener, + Function(Object)? errorListener}) async { + return sendMap(message.toPostJson(defaultChannelId: defaultChannelId), + completeListener: completeListener, errorListener: errorListener); + } + + Future sendMap(Map payload, + {Function(String)? completeListener, + Function(Object)? errorListener}) async { + return send(jsonEncode(payload), + completeListener: completeListener, errorListener: errorListener); + } + + Future send(String jsonString, + {Function(String)? completeListener, + Function(Object)? errorListener}) async { + final decoded = jsonDecode(jsonString); + if (decoded is! Map) { + throw Exception('Mattermost payload must be a JSON object.'); + } + + final payload = normalizeMattermostPost(Map.from(decoded)); + final header = { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }; + final encoded = jsonEncode(payload); + print(encoded); + final response = + await http.post(Uri.parse(url), headers: header, body: encoded); + + print(response.statusCode); + + if (response.statusCode >= 200 && response.statusCode < 300) { + print(response.body); + if (completeListener != null) { + completeListener(response.body); + } + } else { + print(response.body); + if (errorListener != null) { + errorListener(response); + } + throw Exception( + 'Mattermost post failed (${response.statusCode}): ${response.body}'); + } + + return simpleFuture; + } + + Future sendBuild(String appName, MattermostBuildType buildType, Color color, + String version, String downloadURL, + {required String channelId}) async { + final buildURL = 'https://toki-labs.com/cdn/build/'; + final thumbUrl = buildType == MattermostBuildType.android + ? '${buildURL}android.png' + : '${buildURL}appstore.png'; + final projectIcon = '$buildURL$appName/icon.png'; + final buildTypeStr = buildTypeToString(buildType); + final osColor = + buildType == MattermostBuildType.ios ? '#ED145B' : '#81CA3F'; + + final message = DataMattermost() + ..channel_id = channelId + ..message = '${toPascal(appName)} $buildTypeStr build is ready.' + ..props = { + 'attachments': [ + MattermostAttachment() + ..color = color.toHexColor().toCssString() + ..fields = [ + MattermostField.withParam( + toPascal(appName), 'App: ${toPascal(appName)}') + ], + MattermostAttachment() + ..color = osColor + ..author_name = buildTypeStr + ..author_icon = thumbUrl + ..thumb_url = projectIcon + ..title = 'Download' + ..title_link = downloadURL + ..text = + 'Successfully built $buildTypeStr binary file.\nBuild version: **$version**' + ..fields = [MattermostField.withParam('Download URL', downloadURL)], + ].map((item) => item.toJson()).toList(), + }; + + return sendData(message); + } + + String buildTypeToString(MattermostBuildType type) { + final typeStr = type.name; + return typeStr == 'ios' ? 'iOS' : toPascal(typeStr); + } +}