Skip to content

Commit

Permalink
External Integration with Actions (#1956)
Browse files Browse the repository at this point in the history
**objective:** an infrastructure that helps developers create
integrations to extend users memories more easily.

## deploys
- [ ] deploy docs
- [ ] deploy backend
- [ ] deplosy app

## todos
- add support `actions` > `create_memory` to the external integration
  - app, create, update apps
  - backend, create, updates apps
- add support create memories by an external integration
- backend, POST /v2/integrations/{app_id}/user/memories?uid=<uid>,
create memories, extract the meta based on sources.
    - source_text_type: email, posts, messages, other;
    - source_text_spec: str, twitter posts, linkedin messages, emails
- basic auth, verify uid is enabled the app, the app have capacity
action > create_memory
   - more secure with bearer, sk-***KHY
- add docs
- create an example for the new capacity

#1921
  • Loading branch information
beastoin authored Mar 8, 2025
2 parents ce2f2fb + c34b005 commit e9028f8
Show file tree
Hide file tree
Showing 31 changed files with 2,415 additions and 295 deletions.
59 changes: 59 additions & 0 deletions app/lib/backend/http/api/apps.dart
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,65 @@ Future<String> getGenratedDescription(String name, String description) async {
}
}

// API Keys
Future<List<AppApiKey>> listApiKeysServer(String appId) async {
var response = await makeApiCall(
url: '${Env.apiBaseUrl}v1/apps/$appId/keys',
headers: {},
body: '',
method: 'GET',
);
try {
if (response == null || response.statusCode != 200) return [];
log('listApiKeysServer: ${response.body}');
return AppApiKey.fromJsonList(jsonDecode(response.body));
} catch (e, stackTrace) {
debugPrint(e.toString());
CrashReporting.reportHandledCrash(e, stackTrace);
return [];
}
}

Future<Map<String, dynamic>> createApiKeyServer(String appId) async {
var response = await makeApiCall(
url: '${Env.apiBaseUrl}v1/apps/$appId/keys',
headers: {},
body: '',
method: 'POST',
);
try {
if (response == null || response.statusCode != 200) {
throw Exception('Failed to create API key');
}
log('createApiKeyServer: ${response.body}');
return jsonDecode(response.body);
} catch (e, stackTrace) {
debugPrint(e.toString());
CrashReporting.reportHandledCrash(e, stackTrace);
throw Exception('Failed to create API key: ${e.toString()}');
}
}

Future<bool> deleteApiKeyServer(String appId, String keyId) async {
var response = await makeApiCall(
url: '${Env.apiBaseUrl}v1/apps/$appId/keys/$keyId',
headers: {},
body: '',
method: 'DELETE',
);
try {
if (response == null || response.statusCode != 200) {
throw Exception('Failed to delete API key');
}
log('deleteApiKeyServer: ${response.body}');
return true;
} catch (e, stackTrace) {
debugPrint(e.toString());
CrashReporting.reportHandledCrash(e, stackTrace);
throw Exception('Failed to delete API key: ${e.toString()}');
}
}

Future<Map> createPersonaApp(File file, Map<String, dynamic> personaData) async {
var request = http.MultipartRequest(
'POST',
Expand Down
118 changes: 108 additions & 10 deletions app/lib/backend/schema/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -78,23 +78,45 @@ class AuthStep {
}
}

class Action {
String action;

Action({
required this.action,
});

factory Action.fromJson(Map<String, dynamic> json) {
return Action(
action: json['action'],
);
}

toJson() {
return {
'action': action,
};
}
}

class ExternalIntegration {
String triggersOn;
String webhookUrl;
String? triggersOn;
String? webhookUrl;
String? setupCompletedUrl;
String setupInstructionsFilePath;
bool isInstructionsUrl;
List<AuthStep> authSteps;
String? setupInstructionsFilePath;
bool? isInstructionsUrl;
List<AuthStep> authSteps = [];
String? appHomeUrl;
List<Action>? actions;

ExternalIntegration({
required this.triggersOn,
required this.webhookUrl,
required this.setupCompletedUrl,
required this.setupInstructionsFilePath,
required this.isInstructionsUrl,
this.triggersOn,
this.webhookUrl,
this.setupCompletedUrl,
this.setupInstructionsFilePath,
this.isInstructionsUrl,
this.authSteps = const [],
this.appHomeUrl,
this.actions,
});

factory ExternalIntegration.fromJson(Map<String, dynamic> json) {
Expand All @@ -108,6 +130,7 @@ class ExternalIntegration {
authSteps: json['auth_steps'] == null
? []
: (json['auth_steps'] ?? []).map<AuthStep>((e) => AuthStep.fromJson(e)).toList(),
actions: json['actions'] == null ? null : (json['actions'] ?? []).map<Action>((e) => Action.fromJson(e)).toList(),
);
}

Expand All @@ -131,6 +154,7 @@ class ExternalIntegration {
'is_instructions_url': isInstructionsUrl,
'setup_instructions_file_path': setupInstructionsFilePath,
'auth_steps': authSteps.map((e) => e.toJson()).toList(),
'actions': actions?.map((e) => e.toJson()).toList(),
};
}
}
Expand Down Expand Up @@ -425,11 +449,14 @@ class AppCapability {
String id;
List<TriggerEvent> triggerEvents = [];
List<NotificationScope> notificationScopes = [];
List<CapacityAction> actions = [];

AppCapability({
required this.title,
required this.id,
this.triggerEvents = const [],
this.notificationScopes = const [],
this.actions = const [],
});

factory AppCapability.fromJson(Map<String, dynamic> json) {
Expand All @@ -438,6 +465,7 @@ class AppCapability {
id: json['id'],
triggerEvents: TriggerEvent.fromJsonList(json['triggers'] ?? []),
notificationScopes: NotificationScope.fromJsonList(json['scopes'] ?? []),
actions: CapacityAction.fromJsonList(json['actions'] ?? []),
);
}

Expand All @@ -447,6 +475,7 @@ class AppCapability {
'id': id,
'triggers': triggerEvents.map((e) => e.toJson()).toList(),
'scopes': notificationScopes.map((e) => e.toJson()).toList(),
'actions': actions.map((e) => e.toJson()).toList(),
};
}

Expand All @@ -456,6 +485,39 @@ class AppCapability {

bool hasTriggers() => triggerEvents.isNotEmpty;
bool hasScopes() => notificationScopes.isNotEmpty;
bool hasActions() => actions.isNotEmpty;
}

class CapacityAction {
String title;
String id;
String? docUrl;

CapacityAction({
required this.title,
required this.id,
this.docUrl,
});

factory CapacityAction.fromJson(Map<String, dynamic> json) {
return CapacityAction(
title: json['title'],
id: json['id'],
docUrl: json['doc_url'],
);
}

toJson() {
return {
'title': title,
'id': id,
'doc_url': docUrl,
};
}

static List<CapacityAction> fromJsonList(List<dynamic> jsonList) {
return jsonList.map((e) => CapacityAction.fromJson(e)).toList();
}
}

class TriggerEvent {
Expand Down Expand Up @@ -559,3 +621,39 @@ class PaymentPlan {
return jsonList.map((e) => PaymentPlan.fromJson(e)).toList();
}
}

class AppApiKey {
final String id;
final String label;
final DateTime createdAt;
String? secret; // Only available when first created

AppApiKey({
required this.id,
required this.label,
required this.createdAt,
this.secret,
});

factory AppApiKey.fromJson(Map<String, dynamic> json) {
return AppApiKey(
id: json['id'],
label: json['label'] ?? 'API Key',
createdAt: DateTime.parse(json['created_at']).toLocal(),
secret: json['secret'],
);
}

toJson() {
return {
'id': id,
'label': label,
'created_at': createdAt.toUtc().toIso8601String(),
if (secret != null) 'secret': secret,
};
}

static List<AppApiKey> fromJsonList(List<dynamic> jsonList) {
return jsonList.map((e) => AppApiKey.fromJson(e)).toList();
}
}
12 changes: 7 additions & 5 deletions app/lib/pages/apps/add_app.dart
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:friend_private/pages/payments/payment_method_provider.dart';
import 'package:friend_private/pages/payments/payments_page.dart';
import 'package:shimmer/shimmer.dart';
import 'package:friend_private/backend/preferences.dart';
import 'package:friend_private/pages/apps/widgets/full_screen_image_viewer.dart';
import 'package:friend_private/backend/schema/app.dart';
import 'package:friend_private/pages/apps/app_detail/app_detail.dart';
import 'package:friend_private/pages/apps/providers/add_app_provider.dart';
import 'package:friend_private/pages/apps/widgets/app_metadata_widget.dart';
import 'package:friend_private/pages/apps/widgets/external_trigger_fields_widget.dart';
import 'package:friend_private/pages/apps/widgets/full_screen_image_viewer.dart';
import 'package:friend_private/pages/apps/widgets/api_keys_widget.dart';
import 'package:friend_private/pages/apps/widgets/notification_scopes_chips_widget.dart';
import 'package:friend_private/pages/apps/widgets/payment_details_widget.dart';
import 'package:friend_private/pages/payments/payment_method_provider.dart';
import 'package:friend_private/pages/payments/payments_page.dart';
import 'package:friend_private/providers/app_provider.dart';
import 'package:friend_private/utils/analytics/mixpanel.dart';
import 'package:friend_private/utils/other/temp.dart';
import 'package:friend_private/widgets/confirmation_dialog.dart';
import 'package:provider/provider.dart';
import 'package:shimmer/shimmer.dart';
import 'package:url_launcher/url_launcher.dart';

import 'widgets/capabilities_chips_widget.dart';
Expand Down Expand Up @@ -372,6 +373,7 @@ class _AddAppPageState extends State<AddAppPage> {
),
],
),

const SizedBox(
height: 22,
),
Expand Down
16 changes: 8 additions & 8 deletions app/lib/pages/apps/app_detail/app_detail.dart
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,9 @@ class _AppDetailPageState extends State<AppDetailPage> {
context.read<AppProvider>().setIsAppPublicToggled(!app.private);
});
if (app.worksExternally()) {
if (app.externalIntegration!.setupInstructionsFilePath.isNotEmpty) {
if (app.externalIntegration!.setupInstructionsFilePath.contains('raw.githubusercontent.com')) {
getAppMarkdown(app.externalIntegration!.setupInstructionsFilePath).then((value) {
if (app.externalIntegration!.setupInstructionsFilePath?.isNotEmpty == true) {
if (app.externalIntegration!.setupInstructionsFilePath?.contains('raw.githubusercontent.com') == true) {
getAppMarkdown(app.externalIntegration!.setupInstructionsFilePath ?? '').then((value) {
value = value.replaceAll(
'](assets/',
'](https://raw.githubusercontent.com/BasedHardware/Omi/main/plugins/instructions/${app.id}/assets/',
Expand Down Expand Up @@ -152,7 +152,7 @@ class _AppDetailPageState extends State<AppDetailPage> {
@override
Widget build(BuildContext context) {
bool isIntegration = app.worksExternally();
bool hasSetupInstructions = isIntegration && app.externalIntegration?.setupInstructionsFilePath.isNotEmpty == true;
bool hasSetupInstructions = isIntegration && app.externalIntegration?.setupInstructionsFilePath?.isNotEmpty == true;
bool hasAuthSteps = isIntegration && app.externalIntegration?.authSteps.isNotEmpty == true;
int stepsCount = app.externalIntegration?.authSteps.length ?? 0;
return Scaffold(
Expand Down Expand Up @@ -613,17 +613,17 @@ class _AppDetailPageState extends State<AppDetailPage> {
onTap: () async {
if (app.externalIntegration != null) {
if (app.externalIntegration!.setupInstructionsFilePath
.contains('raw.githubusercontent.com')) {
?.contains('raw.githubusercontent.com') == true) {
await routeToPage(
context,
MarkdownViewer(title: 'Setup Instructions', markdown: instructionsMarkdown ?? ''),
);
} else {
if (app.externalIntegration!.isInstructionsUrl) {
await launchUrl(Uri.parse(app.externalIntegration!.setupInstructionsFilePath));
if (app.externalIntegration!.isInstructionsUrl == true) {
await launchUrl(Uri.parse(app.externalIntegration!.setupInstructionsFilePath ?? ''));
} else {
var m = app.externalIntegration!.setupInstructionsFilePath;
routeToPage(context, MarkdownViewer(title: 'Setup Instructions', markdown: m));
routeToPage(context, MarkdownViewer(title: 'Setup Instructions', markdown: m ?? ''));
}
}
}
Expand Down
Loading

0 comments on commit e9028f8

Please sign in to comment.