Add Mattermost post and build commands

This commit is contained in:
toki 2026-03-23 21:54:37 +09:00
parent d3a9abc004
commit c54161ffa2
11 changed files with 715 additions and 0 deletions

View file

@ -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

View file

@ -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: 정상적으로 전송되면 이 메시지가 채널에 표시됩니다.

View file

@ -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)"
}
]
}
}

View file

@ -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: <!property.mattermostPayload>

View file

@ -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(),

View file

@ -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<String, dynamic>.from(value))
.toPostJson(defaultChannelId: data.channelId),
);
} else {
throw Exception('The property must be DataMattermost or Map.');
}
} else if (data.message != null) {
final payload = Map<String, dynamic>.from(data.message!);
await sender.sendMap(
_replaceMentionsInMap(payload, data.idMap).cast<String, dynamic>());
} 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<String, String>? 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<String, String>? 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);
}
}

View file

@ -149,6 +149,18 @@ commands:
# downloadURL: http://toki-labs.com/build/tplayer/android/tplayer_arm64_<!common.version>.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_<!common.version>.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) {}

View file

@ -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<String, dynamic> toJson() => _$DataSlackBuildToJson(this);
}
//Mattermost
@JsonSerializable()
class DataMattermostSender extends DataParam {
DataMattermostSender();
late String token;
late String url;
late String? channelId;
late dynamic property;
late Map<String, dynamic>? message;
late String? messageJson;
late Map<String, String>? idMap;
factory DataMattermostSender.fromJson(Map<String, dynamic> json) =>
_$DataMattermostSenderFromJson(json);
@override
Map<String, dynamic> 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<String, dynamic> json) =>
_$DataMattermostBuildFromJson(json);
@override
Map<String, dynamic> toJson() => _$DataMattermostBuildToJson(this);
}
//Jira
@JsonSerializable()
class DataJira extends DataParam {

View file

@ -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<String, dynamic> json) =>
DataMattermostSender()
..workspace = json['workspace'] as String?
..passExitCodes = (json['passExitCodes'] as List<dynamic>?)
?.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<String, dynamic>?
..messageJson = json['messageJson'] as String?
..idMap = (json['idMap'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
);
Map<String, dynamic> _$DataMattermostSenderToJson(
DataMattermostSender instance) =>
<String, dynamic>{
'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<String, dynamic> json) =>
DataMattermostBuild()
..workspace = json['workspace'] as String?
..passExitCodes = (json['passExitCodes'] as List<dynamic>?)
?.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<String, dynamic>?
..messageJson = json['messageJson'] as String?
..idMap = (json['idMap'] as Map<String, dynamic>?)?.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<String, dynamic> _$DataMattermostBuildToJson(
DataMattermostBuild instance) =>
<String, dynamic>{
'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<String, dynamic> json) => DataJira()
..workspace = json['workspace'] as String?
..passExitCodes = (json['passExitCodes'] as List<dynamic>?)

View file

@ -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<String, dynamic>? props;
List<MattermostAttachment>? attachments;
String get json {
return jsonEncode(toPostJson());
}
DataMattermost();
factory DataMattermost.fromJson(Map<String, dynamic> 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<String, dynamic>) {
data.props =
Map<String, dynamic>.from(json['props'] as Map<String, dynamic>);
} else if (json['props'] is Map) {
data.props = Map<String, dynamic>.from(json['props'] as Map);
}
if (json['attachments'] is List) {
data.attachments = (json['attachments'] as List)
.map((item) => MattermostAttachment.fromJson(
Map<String, dynamic>.from(item as Map)))
.toList();
}
return data;
}
Map<String, dynamic> 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<String, dynamic> 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<MattermostField>? fields;
List<Map<String, dynamic>>? actions;
MattermostAttachment();
factory MattermostAttachment.fromJson(Map<String, dynamic> 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<String, dynamic>.from(item as Map)))
.toList();
}
if (json['actions'] is List) {
data.actions = (json['actions'] as List)
.map((item) => Map<String, dynamic>.from(item as Map))
.toList();
}
return data;
}
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> toJson() {
return removeEmptyKeys({
'title': title,
'value': value,
'short': short,
});
}
}
Map<String, dynamic> normalizeMattermostPost(Map<String, dynamic> payload,
{String? defaultChannelId}) {
final map = Map<String, dynamic>.from(payload);
final props = map['props'] is Map<String, dynamic>
? Map<String, dynamic>.from(map['props'] as Map<String, dynamic>)
: map['props'] is Map
? Map<String, dynamic>.from(map['props'] as Map)
: <String, dynamic>{};
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<String, dynamic> source,
Map<String, dynamic> 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<String, dynamic> removeEmptyKeys(Map<String, dynamic> map) {
final emptyKeys = <String>[];
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;
}

View file

@ -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<String, dynamic> 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<String, dynamic>.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);
}
}