Commit 843ce171 authored by Daniel Vrátil's avatar Daniel Vrátil 🤖

FB: work around FB API limitation to sync birthdays

With Graph API 2.0 apps can only see friends that also use the same app.
This is not very useful, since for most users the birthday calendar would
be mostly empty. Luckily Facebook provides an iCal with all friends'
birthdays, so we use a bit of cookie magic to download user's Facebook
Events page, parse the iCal URL from it and then fetch the iCal with all
birthdays. Yay for us.
parent 106d015b
......@@ -25,6 +25,7 @@ target_link_libraries(akonadi_facebook_resource
KF5::CalendarCore
KF5::I18n
KF5::Wallet
KF5::Codecs
Qt5::WebEngineWidgets
)
......
......@@ -17,55 +17,149 @@
#include "birthdaylistjob.h"
#include "settings.h"
#include "tokenjobs.h"
#include "resource.h"
#include <QJsonObject>
#include <QDate>
#include <QNetworkCookie>
#include <QByteArrayMatcher>
#include <KCalCore/Event>
#include <KIO/Job>
#include <KLocalizedString>
#include <KCharsets>
#include <KCalCore/MemoryCalendar>
#include <KCalCore/ICalFormat>
BirthdayListJob::BirthdayListJob(const Akonadi::Collection &collection, QObject *parent)
: ListJob(collection, parent)
BirthdayListJob::BirthdayListJob(const Akonadi::Collection &collection, FacebookResource *parent)
: KJob(parent)
, mCollection(collection)
{
setRequest(QStringLiteral("me/friends"),
{ QStringLiteral("id"),
QStringLiteral("name"),
QStringLiteral("birthday") });
}
BirthdayListJob::~BirthdayListJob()
{
}
Akonadi::Item BirthdayListJob::handleResponse(const QJsonObject &data)
QVector<Akonadi::Item> BirthdayListJob::items() const
{
return mItems;
}
void BirthdayListJob::start()
{
auto tokenJob = new GetTokenJob(qobject_cast<FacebookResource*>(parent()));
connect(tokenJob, &GetTokenJob::result,
this, [this, tokenJob]() {
if (tokenJob->error()) {
setError(tokenJob->error());
setErrorText(tokenJob->errorText());
emitResult();
return;
}
// Convert the cookies into a HTTP Cookie header that we can pass
// to KIO
mCookies = QStringLiteral("Cookie: ");
const auto parsedCookies = QNetworkCookie::parseCookies(tokenJob->cookies());
for (const auto &cookie : parsedCookies) {
mCookies += QStringLiteral("%1=%2; ").arg(QString::fromUtf8(cookie.name()),
QString::fromUtf8(cookie.value()));
}
fetchFacebookEventsPage();
});
tokenJob->start();
}
KIO::StoredTransferJob *BirthdayListJob::createGetJob(const QUrl &url) const
{
if (!data.contains(QLatin1String("birthday"))) {
auto job = KIO::storedGet(url, KIO::NoReload, KIO::HideProgressInfo);
job->setMetaData({ QMap<QString,QString>{
{ QStringLiteral("cookies"), QStringLiteral("manual") },
{ QStringLiteral("setcookies"), mCookies } }
});
return job;
}
void BirthdayListJob::emitError(const QString& errorText)
{
setError(KJob::UserDefinedError);
setErrorText(errorText);
emitResult();
}
void BirthdayListJob::fetchFacebookEventsPage()
{
auto job = createGetJob(QUrl(QStringLiteral("https://www.facebook.com/events/birthdays")));
connect(job, &KJob::result,
this, [this, job]() {
if (job->error()) {
emitError(i18n("Failed to retrieve birthday calendar"));
return;
}
auto url = findBirthdayIcalLink(job->data());
if (url.isEmpty()) {
emitError(i18n("Failed to retrieve birthday calendar"));
return;
}
// switch webcal scheme for https so we can fetch it with KIO
url.setScheme(QStringLiteral("https"));
fetchBirthdayIcal(url);
});
job->start();
}
QUrl BirthdayListJob::findBirthdayIcalLink(const QByteArray &data)
{
// QXmlStreamParser cannot deal with Facebook's broken HTML and refuses
// to parse it. But since we know very well what we are looking for and the
// address is very unique in the source code, using QBAMatcher is much more
// efficient than QXmlStreamParser anyway...
QByteArrayMatcher matcher("webcal://www.facebook.com/ical/b.php");
const int start = matcher.indexIn(data);
if (start == -1) {
return {};
}
auto event = KCalCore::Event::Ptr::create();
const int end = data.indexOf('\"', start);
if (end == -1) {
return {};
}
const QString id = data.value(QLatin1String("id")).toString();
event->setUid(id);
const QString name = data.value(QLatin1String("name")).toString();
event->setSummary(i18n("%1's birthday", name));
event->setDescription(QStringLiteral("https://www.facebook.com/%1").arg(id));
auto str = QString::fromUtf8(data.constData() + start, end - start);
return QUrl(KCharsets::resolveEntities(str));
}
const QString birthday = data.value(QLatin1String("birthday")).toString();
const int day = birthday.midRef(3, 2).toInt();
const int month = birthday.midRef(0, 2).toInt();
int year = birthday.length() > 6 ? birthday.midRef(6).toInt() : QDate::currentDate().year();
if (year < 100) { // handle just two-digit years, assume it's 1900's
year += 1900;
}
QDate dt(year, month, day);
void BirthdayListJob::fetchBirthdayIcal(const QUrl &url)
{
auto job = createGetJob(url);
connect(job, &KJob::result,
this, [this, job]() {
if (job->error()) {
emitError(job->errorText());
return;
}
auto cal = KCalCore::MemoryCalendar::Ptr::create(KDateTime::LocalZone);
KCalCore::ICalFormat format;
if (!format.fromRawString(cal, job->data(), false)) {
emitError(i18n("Failed to parse birthday calendar"));
return;
}
event->setDtStart(KDateTime(dt));
event->setAllDay(true);
auto recurrence = event->recurrence();
recurrence->setYearly(1);
const auto events = cal->events();
for (const auto &event : events) {
processEvent(event);
}
emitResult();
});
}
void BirthdayListJob::processEvent(const KCalCore::Event::Ptr &event)
{
if (Settings::self()->birthdayReminders()) {
auto alarm = KCalCore::Alarm::Ptr::create(event.data());
alarm->setDisplayAlarm(event->summary());
......@@ -75,11 +169,16 @@ Akonadi::Item BirthdayListJob::handleResponse(const QJsonObject &data)
event->addAlarm(alarm);
}
const auto uid = event->uid(); // b123456789@facebook.com
const auto id = uid.mid(1, uid.indexOf(QLatin1Char('@')) - 2); // 123456789
event->setDescription(QStringLiteral("https://www.facebook.com/%1").arg(id));
Akonadi::Item item;
item.setRemoteId(id);
item.setGid(id);
item.setRemoteId(uid);
item.setGid(uid);
item.setMimeType(KCalCore::Event::eventMimeType());
item.setParentCollection(collection());
item.setParentCollection(mCollection);
item.setPayload(event);
return item;
mItems.push_back(item);
}
......@@ -18,18 +18,43 @@
#ifndef FACEBOOK_BIRTHDAYLISTJOB_H_
#define FACEBOOK_BIRTHDAYLISTJOB_H_
#include "listjob.h"
#include <KJob>
class BirthdayListJob : public ListJob
#include <AkonadiCore/Collection>
#include <AkonadiCore/Item>
#include <KCalCore/Event>
class QUrl;
class FacebookResource;
namespace KIO {
class StoredTransferJob;
}
class BirthdayListJob : public KJob
{
Q_OBJECT
public:
BirthdayListJob(const Akonadi::Collection &collection, QObject *parent = nullptr);
BirthdayListJob(const Akonadi::Collection &collection, FacebookResource *parent);
~BirthdayListJob() override;
protected:
Akonadi::Item handleResponse(const QJsonObject &data) override;
void start() override;
QVector<Akonadi::Item> items() const;
private:
KIO::StoredTransferJob *createGetJob(const QUrl &url) const;
void emitError(const QString &errorText);
void fetchFacebookEventsPage();
QUrl findBirthdayIcalLink(const QByteArray &data);
void fetchBirthdayIcal(const QUrl &url);
void processEvent(const KCalCore::Event::Ptr &event);
Akonadi::Collection mCollection;
QVector<Akonadi::Item> mItems;
QString mCookies;
};
#endif
......@@ -110,15 +110,18 @@ void FacebookResource::retrieveItems(const Akonadi::Collection &collection)
{
setItemStreamingEnabled(true);
ListJob *job = nullptr;
KJob *job = nullptr;
if (Graph::rsvpFromString(collection.remoteId()) == Graph::Birthday) {
job = new BirthdayListJob(collection, this);
} else {
job = new EventsListJob(collection, this);
connect(static_cast<ListJob*>(job), &ListJob::itemsAvailable,
this, [this](KJob*, const Akonadi::Item::List &items) {
itemsRetrieved(items);
});
}
job->setProperty("collection", QVariant::fromValue(collection));
connect(job, &KJob::result, this, &FacebookResource::onRetrieveItemsDone);
connect(job, &ListJob::itemsAvailable, this, &FacebookResource::onItemsAvailable);
connect(job, &KJob::result, this, &FacebookResource::onListJobDone);
job->start();
mCurrentJob = job;
}
......@@ -134,15 +137,7 @@ bool FacebookResource::retrieveItems(const Akonadi::Item::List &items, const QSe
}
void FacebookResource::onItemsAvailable(KJob *job, const Akonadi::Item::List &items)
{
Q_ASSERT(mCurrentJob == job);
itemsRetrieved(items);
}
void FacebookResource::onRetrieveItemsDone(KJob *job)
void FacebookResource::onListJobDone(KJob *job)
{
if (job->error()) {
qCWarning(RESOURCE_LOG) << "Item sync error:" << job->errorString();
......@@ -150,12 +145,17 @@ void FacebookResource::onRetrieveItemsDone(KJob *job)
return;
}
// Birthday job does not have item streaming
if (auto bjob = qobject_cast<BirthdayListJob*>(job)) {
itemsRetrieved(bjob->items());
}
itemsRetrievalDone();
}
int main(int argc, char **argv)
{
// Enable to debug Facebook authentication
//qputenv("QTWEBENGINE_REMOTE_DEBUGGING", "8080");
qputenv("QTWEBENGINE_REMOTE_DEBUGGING", "8080");
return Akonadi::ResourceBase::init<FacebookResource>(argc, argv);
}
......@@ -41,8 +41,7 @@ public:
bool retrieveItems(const Akonadi::Item::List &items, const QSet<QByteArray> &parts) override;
private Q_SLOTS:
void onItemsAvailable(KJob *job, const Akonadi::Item::List &items);
void onRetrieveItemsDone(KJob *job);
void onListJobDone(KJob *job);
private:
Akonadi::Collection makeCollection(Graph::RSVP rsvp, const QString &name,
......
......@@ -456,6 +456,11 @@ QString GetTokenJob::userName() const
return d->userName;
}
QByteArray GetTokenJob::cookies() const
{
return d->cookies;
}
void GetTokenJob::start()
{
// Already have token, so we are done
......
......@@ -75,6 +75,7 @@ public:
QString token() const;
QString userName() const;
QString userId() const;
QByteArray cookies() const;
void start() override;
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment