diff --git a/api/services/youtube.json b/api/services/youtube.json index 3b984cc..92e6f61 100644 --- a/api/services/youtube.json +++ b/api/services/youtube.json @@ -13,11 +13,11 @@ }, "params": [ { - "name": "channel", + "name": "channel_id", "type": "string", "description": { - "en": "Name of the channel to watch over", - "fr": "Le nom de la chaîne à regarder" + "en": "ID of the channel to watch over", + "fr": "L'ID de la chaîne à regarder" } } ], diff --git a/api/src/Api/OIDC.hs b/api/src/Api/OIDC.hs index 6b11a72..4eb3cdd 100644 --- a/api/src/Api/OIDC.hs +++ b/api/src/Api/OIDC.hs @@ -57,7 +57,7 @@ urlHandler Twitter (Just r) = do clientId <- liftIO $ envAsString "TWITTER_CLIENT_ID" "" backRedirect <- liftIO $ envAsString "BACK_URL" "" throwError $ err302 { errHeaders = - [("Location", B8.pack $ "https://twitter.com/i/oauth2/authorize?response_type=code&scope=like.write like.read follows.read follows.write offline.access tweet.read tweet.write&code_challenge=challenge&code_challenge_method=plain&client_id=" ++ clientId ++ "&redirect_uri=" ++ backRedirect ++ "auth/redirect" ++ "&state=" ++ r)] } + [("Location", B8.pack $ "https://twitter.com/i/oauth2/authorize?response_type=code&scope=like.write like.read follows.read follows.write offline.access tweet.read tweet.write users.read&code_challenge=challenge&code_challenge_method=plain&client_id=" ++ clientId ++ "&redirect_uri=" ++ backRedirect ++ "auth/redirect" ++ "&state=" ++ r)] } urlHandler Spotify (Just r) = do clientId <- liftIO $ envAsString "SPOTIFY_CLIENT_ID" "" backRedirect <- liftIO $ envAsString "BACK_URL" "" diff --git a/mobile/Dockerfile b/mobile/Dockerfile index 22c42db..649aa12 100644 --- a/mobile/Dockerfile +++ b/mobile/Dockerfile @@ -12,9 +12,10 @@ RUN flutter upgrade && flutter doctor RUN which flutter -COPY . . +COPY pubspec.yaml pubspec.lock ./ RUN flutter pub get +COPY . . # Generate traduction files RUN flutter gen-l10n # Generate launcher icon diff --git a/mobile/lib/src/models/pipeline.dart b/mobile/lib/src/models/pipeline.dart index afac62c..16204df 100644 --- a/mobile/lib/src/models/pipeline.dart +++ b/mobile/lib/src/models/pipeline.dart @@ -17,6 +17,9 @@ class Pipeline { /// Is the pipeline enabled bool enabled; + /// An error trace, if exists + String? errorMessage; + ///The pipeline's reactions final List reactions; @@ -27,6 +30,7 @@ class Pipeline { required this.name, required this.triggerCount, required this.enabled, + this.errorMessage, required this.trigger, required this.reactions}); @@ -36,6 +40,7 @@ class Pipeline { var reactions = data['reactions'] as List; return Pipeline( + errorMessage: action['error'], name: action['name'] as String, enabled: action['enabled'] as bool, id: action['id'] as int, diff --git a/mobile/lib/src/views/pipeline_detail_page.dart b/mobile/lib/src/views/pipeline_detail_page.dart index 983f30b..cbb18eb 100644 --- a/mobile/lib/src/views/pipeline_detail_page.dart +++ b/mobile/lib/src/views/pipeline_detail_page.dart @@ -148,9 +148,45 @@ class _PipelineDetailPageState extends State { child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.only(bottom: 40), + padding: const EdgeInsets.only(bottom: 30), child: cardHeader, ), + pipeline.errorMessage != null + ? Padding( + child: Card( + elevation: 0, + color: Theme.of(context).colorScheme.errorContainer.withAlpha(100), + shape: RoundedRectangleBorder( + side: BorderSide( + color: Theme.of(context).colorScheme.error + ), + borderRadius: const BorderRadius.all(Radius.circular(4)), + ), + child: Padding( + padding: const EdgeInsets.only(right: 10, top: 10, bottom: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded(flex: 2, + child: Icon( + Icons.warning, + color: Theme.of(context).colorScheme.onErrorContainer + ) + ), + Expanded(flex: 8, child: Text( + pipeline.errorMessage!, + maxLines: 5, overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Theme.of(context).colorScheme.onErrorContainer + ), + )) + ] + ) + ), + ), + padding: const EdgeInsets.only(bottom: 20), + ) + : Container(), Text(AppLocalizations.of(context).action, style: const TextStyle(fontWeight: FontWeight.w500)), ActionDetailCard( diff --git a/mobile/lib/src/widgets/pipeline_card.dart b/mobile/lib/src/widgets/pipeline_card.dart index a6b5d90..5bb9630 100644 --- a/mobile/lib/src/widgets/pipeline_card.dart +++ b/mobile/lib/src/widgets/pipeline_card.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:aeris/src/models/pipeline.dart'; import 'package:aeris/src/views/pipeline_detail_page.dart'; import 'package:aeris/src/widgets/clickable_card.dart'; - +import 'package:badges/badges.dart'; import 'aeris_card_page.dart'; /// Widget for Action-reaction card on home page @@ -27,7 +27,11 @@ class _PipelineCardState extends State { (array, logo) => array + [logo, const SizedBox(height: 5)]).toList(); reactionLogos.removeLast(); - return ClickableCard( + return Badge( + showBadge: widget.pipeline.errorMessage != null, + badgeContent: Icon(Icons.priority_high, color: Theme.of(context).colorScheme.surface), + position: const BadgePosition(end: -3, top: -5), + child: ClickableCard( onTap: () { showAerisCardPage( context, @@ -76,6 +80,6 @@ class _PipelineCardState extends State { const SizedBox(width: 10), Column(children: reactionLogos) ])), - ]))); + ])))); } } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 5b0d21c..9b79b92 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -43,6 +43,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.8.2" + badges: + dependency: "direct main" + description: + name: badges + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" boolean_selector: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 6ce185b..7183bb9 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -61,6 +61,7 @@ dependencies: flutter_typeahead: ^3.2.4 simple_autocomplete_formfield: ^0.3.0 positioned_tap_detector_2: ^1.0.4 + badges: ^2.0.2 dev_dependencies: flutter_launcher_icons: "^0.9.2" diff --git a/worker/src/index.ts b/worker/src/index.ts index b572bcd..9768c19 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -38,7 +38,7 @@ app.post("/workflow/:id", (req, res) => { app.delete("/workflow/:id", (req, res) => { console.log(`delete pipeline ${req.params.id}`); pipelineEvent.emit("event", { - id: req.params.id, + id: parseInt(req.params.id), type: PipelineType.Never, }); res.send() diff --git a/worker/src/models/base-service.ts b/worker/src/models/base-service.ts index ddb2d7e..72de167 100644 --- a/worker/src/models/base-service.ts +++ b/worker/src/models/base-service.ts @@ -54,7 +54,7 @@ export class BaseService { getReaction(reaction: ReactionType): (params: any) => Promise { const metadata: ActionMetadata = BaseService._reactions[this.constructor.name][ReactionType[reaction]]; if (!metadata) - throw new TypeError(`Invalid reaction: ${action}`); + throw new TypeError(`Invalid reaction: ${reaction}`); return this._runWithParamsCheck(metadata); } diff --git a/worker/src/models/pipeline.ts b/worker/src/models/pipeline.ts index ac09c2d..ee5857d 100644 --- a/worker/src/models/pipeline.ts +++ b/worker/src/models/pipeline.ts @@ -73,11 +73,11 @@ export enum ReactionType { ToggleFavourite, UpdateAbout, // Twitter - followUser, - postTweet, - replyToTweet, - likeTweet, - retweet + FollowUser, + PostTweet, + ReplyToTweet, + LikeTweet, + Retweet }; export class Pipeline { @@ -96,6 +96,7 @@ export class Token { accessToken: string; refreshToken: string; expiresAt: string; + providerId: string; }; export class Reaction { @@ -131,7 +132,8 @@ export const pipelineFromApi = (data: any): Pipeline => { { accessToken: x.accessToken, refreshToken: x.refreshToken, - expiresAt: x.expiresAt + expiresAt: x.expiresAt, + providerId: x.providerId, } as Token ])), }; diff --git a/worker/src/services/spotify.ts b/worker/src/services/spotify.ts index 5d18581..8647395 100644 --- a/worker/src/services/spotify.ts +++ b/worker/src/services/spotify.ts @@ -26,8 +26,6 @@ export class Spotify extends BaseService { private async _refreshIfNeeded(): Promise { if (Date.parse(this._pipeline.userData["Spotify"].expiresAt) >= Date.now() + 100_000) return; - console.log("refreshing spotify") - console.table(this._pipeline.userData['Spotify']) const ret = await this._spotify.refreshAccessToken(); const data = this._pipeline.userData["Spotify"]; data.accessToken = ret.body.access_token; @@ -49,7 +47,6 @@ export class Spotify extends BaseService { listenAddToPlaylist(params: any): Observable { return Utils.longPulling(async since => { await this._refreshIfNeeded(); - console.log("pulling spotify") let ret = await this._spotify.getPlaylistTracks(params.playlistId); return ret.body.items .filter(x => new Date(x.added_at) >= since) diff --git a/worker/src/services/twitter.ts b/worker/src/services/twitter.ts index d5a260e..5e47af9 100644 --- a/worker/src/services/twitter.ts +++ b/worker/src/services/twitter.ts @@ -1,46 +1,46 @@ -import { exhaustMap, from, fromEventPattern, map, Observable } from "rxjs"; -import { Pipeline, PipelineEnv, PipelineType, ReactionType, ServiceType } from "../models/pipeline"; -import { ETwitterStreamEvent, TweetStream, TwitterApi } from "twitter-api-v2"; -import { action, BaseService, reaction, service } from "../models/base-service"; +import { Pipeline, PipelineEnv, ReactionType, ServiceType } from "../models/pipeline"; +import { TwitterApi } from "twitter-api-v2"; +import { BaseService, reaction, service } from "../models/base-service"; @service(ServiceType.Twitter) export class Twitter extends BaseService { - constructor(_: Pipeline) { + private _twitter: TwitterApi; + private _pipeline: Pipeline; + + constructor(pipeline: Pipeline) { super(); + this._pipeline = pipeline; + this._twitter = new TwitterApi(pipeline.userData["Twitter"].accessToken); } - private static _createTwitter() { - return new TwitterApi(); ///TODO Get API KEY + private async _refreshIfNeeded(): Promise { + if (Date.parse(this._pipeline.userData["Twitter"].expiresAt) >= Date.now() + 100_000) + return; + const ret = await (new TwitterApi({ + clientId: process.env["TWITTER_CLIENT_ID"], + clientSecret: process.env["TWITTER_SECRET"], + })).refreshOAuth2Token(this._pipeline.userData["Twitter"].refreshToken); + const data = this._pipeline.userData["Twitter"]; + this._twitter = ret.client; + data.accessToken = ret.accessToken; + if (ret.refreshToken) + data.refreshToken = ret.refreshToken; + data.expiresAt = new Date(Date.now() + ret.expiresIn * 1000).toISOString(); + fetch(`${process.env["WORKER_API_URL"]}/twitter/${this._pipeline.userId}?WORKER_API_KEY=${process.env["WORKER_API_KEY"]}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); } - private static async _createStream(): Promise { - const client: TwitterApi = this._createTwitter(); - const stream = await client.v2.sampleStream(); - stream.on(ETwitterStreamEvent.Connected, () => console.log('Stream is started.')); - stream.on(ETwitterStreamEvent.ConnectionError, err => console.log('Connection error!', err)); - stream.on(ETwitterStreamEvent.ConnectionClosed, () => console.log('Connection has been closed.')); - return stream; - } - - - @action(PipelineType.OnTweet, []) - static listenTweet(params: any): Observable { - return from(Twitter._createStream()) - .pipe( - exhaustMap((stream: TweetStream) => - fromEventPattern( - handler => stream.on(ETwitterStreamEvent.Data, handler), - () => stream.close() - ) - ) - ); - } - - @reaction(ReactionType.followUser, ['user_name']) - static async followUser(params: any): Promise { - let client: TwitterApi = this._createTwitter(); - let user = await client.v2.userByUsername(params['user_name']); - client.v2.follow((await client.currentUser()).id_str, user.data.id); + @reaction(ReactionType.FollowUser, ['user_name']) + async followUser(params: any): Promise { + await this._refreshIfNeeded(); + let user = await this._twitter.v2.userByUsername(params['user_name']); + const me = (await this._twitter.v2.me()).data.id; + this._twitter.v2.follow(me, user.data.id); return { FOLLOWED_ID: user.data.id, FOLLOWED_NAME: user.data.name, @@ -49,20 +49,20 @@ export class Twitter extends BaseService { } } - @reaction(ReactionType.postTweet, ['tweet_content']) - static async postTweet(params: any): Promise { - let client: TwitterApi = this._createTwitter(); - let tweet = await client.v2.tweet(params['tweet_content']); + @reaction(ReactionType.PostTweet, ['tweet_content']) + async postTweet(params: any): Promise { + await this._refreshIfNeeded(); + let tweet = await this._twitter.v2.tweet(params['tweet_content']); return { TWEET_ID: tweet.data.id, TWEET_CONTENT: tweet.data.text, } } - @reaction(ReactionType.replyToTweet, ['tweet_id', 'reply_body']) - static async replyToTweet(params: any): Promise { - let client: TwitterApi = this._createTwitter(); - let reply = await client.v2.reply( + @reaction(ReactionType.ReplyToTweet, ['tweet_id', 'reply_body']) + async replyToTweet(params: any): Promise { + await this._refreshIfNeeded(); + let reply = await this._twitter.v2.reply( params['reply_body'], params['tweet_id'], ); @@ -73,10 +73,12 @@ export class Twitter extends BaseService { } } - @reaction(ReactionType.likeTweet, ['tweet_id']) - static async likeTweet(params: any): Promise { - let client: TwitterApi = this._createTwitter(); - let tweet = (await client.v2.tweets([params['tweet_id']])).data[0]; + @reaction(ReactionType.LikeTweet, ['tweet_id']) + async likeTweet(params: any): Promise { + await this._refreshIfNeeded(); + const me = (await this._twitter.v2.me()).data.id; + await this._twitter.v2.like(me, params['tweet_id']); + let tweet = (await this._twitter.v2.tweets([params['tweet_id']])).data[0]; return { TWEET_ID: tweet.id, TWEET_CONTENT: tweet.text, @@ -84,10 +86,10 @@ export class Twitter extends BaseService { } } - @reaction(ReactionType.retweet, ['tweet_id']) - static async retweet(params: any): Promise { - let client: TwitterApi = this._createTwitter(); - let tweet = await client.v2.retweet((await client.currentUser()).id_str, params['tweet_id']); + @reaction(ReactionType.Retweet, ['tweet_id']) + async retweet(params: any): Promise { + await this._refreshIfNeeded(); + let tweet = await this._twitter.v2.retweet((await this._twitter.v2.me()).data.id, params['tweet_id']); return { TWEET_ID: params['tweet_id'] } diff --git a/worker/src/services/youtube.ts b/worker/src/services/youtube.ts index cd3f2b6..9457438 100644 --- a/worker/src/services/youtube.ts +++ b/worker/src/services/youtube.ts @@ -40,12 +40,12 @@ export class Youtube extends BaseService { }); } - @action(PipelineType.OnYtUpload, ["channel"]) + @action(PipelineType.OnYtUpload, ["channel_id"]) listenChannel(params: any): Observable { return Utils.longPulling(async (since) => { const ret = await this._youtube.activities.list({ - part: ["snippet"], - channelId: params.channel, + part: ["snippet", "contentDetails"], + channelId: params.channel_id, maxResults: 25, publishedAfter: since.toISOString(), }, {}); @@ -134,10 +134,12 @@ export class Youtube extends BaseService { @reaction(ReactionType.YtAddToPlaylist, ["videoId", "playlistId"]) async reactPlaylist(params: any): Promise { await this._youtube.playlistItems.insert({ + part: ["snippet"], requestBody: { snippet: { resourceId: { videoId: params.videoId, + kind: "youtube#video" }, playlistId: params.playlistId, },