Skip to content

Commit

Permalink
send DMs to opt in users every day
Browse files Browse the repository at this point in the history
  • Loading branch information
PLhery committed Jun 22, 2023
1 parent 9dd2b6a commit 0702ad6
Show file tree
Hide file tree
Showing 10 changed files with 373 additions and 35 deletions.
3 changes: 2 additions & 1 deletion unfollow-ninja-server/src/api.ts
Expand Up @@ -43,11 +43,12 @@ const adminRouter = createAdminRouter(dao);

export interface NinjaSession {
twitterTokenSecret?: Record<string, string>;
twitterCodeVerifier?: Record<string, string>;
userId?: string;
username?: string;
profilePic?: string;
fullName?: string;
otherProfiles?: Record<string, Omit<NinjaSession, 'twitterTokenSecret' | 'otherProfiles'>>; // key: userId
otherProfiles?: Record<string, Omit<NinjaSession, 'twitterTokenSecret' | 'otherProfiles' | 'twitterCodeVerifier'>>; // key: userId
}

const router = new Router()
Expand Down
69 changes: 55 additions & 14 deletions unfollow-ninja-server/src/api/auth.ts
Expand Up @@ -10,26 +10,20 @@ import { WebEvent } from '../dao/userEventDao';

const authRouter = new Router();

if (
!process.env.CONSUMER_KEY ||
!process.env.CONSUMER_SECRET ||
!process.env.DM_CONSUMER_KEY ||
!process.env.DM_CONSUMER_SECRET
) {
if (!process.env.CONSUMER_KEY || !process.env.CONSUMER_SECRET) {
logger.error('Some required environment variables are missing ((DM_)CONSUMER_KEY/CONSUMER_SECRET).');
logger.error('Make sure you added them in a .env file in you cwd or that you defined them.');
process.exit();
}

/*const _STEP1_CREDENTIALS = {
const _STEP1_CREDENTIALS = {
appKey: process.env.CONSUMER_KEY,
appSecret: process.env.CONSUMER_SECRET,
} as const;*/
const _STEP2_CREDENTIALS = {
appKey: process.env.DM_CONSUMER_KEY,
appSecret: process.env.DM_CONSUMER_SECRET,
} as const;
const _STEP1_CREDENTIALS = _STEP2_CREDENTIALS;
const _DM_CREDENTIALS = {
clientId: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
} as const;

if (!process.env.API_URL || !process.env.WEB_URL) {
logger.error('Some required environment variables are missing (API_URL/WEB_URL).');
Expand Down Expand Up @@ -158,8 +152,8 @@ export function createAuthRouter(dao: Dao) {
const twitterApi = new TwitterApi({
accessToken: loginResult.accessToken,
accessSecret: loginResult.accessSecret,
appKey: process.env.DM_CONSUMER_KEY,
appSecret: process.env.DM_CONSUMER_SECRET,
appKey: process.env.CONSUMER_KEY,
appSecret: process.env.CONSUMER_SECRET,
});
const result = await twitterApi.v2.me({ 'user.fields': ['profile_image_url'] });

Expand Down Expand Up @@ -201,6 +195,53 @@ export function createAuthRouter(dao: Dao) {
close();
</script>`;
})
.get('/dm-auth', async (ctx) => {
// Generate an authentication URL
const { url, state, codeVerifier } = await new TwitterApi(_DM_CREDENTIALS).generateOAuth2AuthLink(
process.env.API_URL + '/auth/dm-auth-callback',
{ scope: ['dm.write', 'tweet.read', 'users.read', 'offline.access'] }
);

const session = ctx.session as NinjaSession;
// store the relevant information in the session
session.twitterCodeVerifier = ctx.session.twitterCodeVerifier || {};
session.twitterCodeVerifier[state] = codeVerifier;

// redirect to the authentication URL
ctx.redirect(url);
})
.get('/dm-auth-callback', async (ctx) => {
const { state, code } = ctx.query;
if (typeof state !== 'string' || typeof code !== 'string') {
ctx.body = { status: 'Oops, it looks like you refused to log in..' };
ctx.status = 401;
return;
}
const codeVerifier = (ctx.session as NinjaSession).twitterCodeVerifier?.[state];
if (typeof codeVerifier !== 'string') {
ctx.body = {
status: 'Oops, it looks like your session has expired.. Try again!',
};
ctx.status = 401;
return;
}

const { client, accessToken, refreshToken } = await new TwitterApi(_DM_CREDENTIALS).loginWithOAuth2({

Check warning on line 229 in unfollow-ninja-server/src/api/auth.ts

View workflow job for this annotation

GitHub Actions / build (18.x)

'accessToken' is assigned a value but never used
code,
codeVerifier,
redirectUri: process.env.API_URL + '/auth/dm-auth-callback',
});

const { id } = (await client.v2.me()).data;
if (id !== ctx.session.userId) {
ctx.body = { status: 'Oops, it looks like you logged in with the wrong account..' };
ctx.status = 401;
return;
}
await dao.getUserDao(id).setUserParams({ dmRefreshToken: refreshToken });
ctx.status = 200;
ctx.body = 'You successfully logged in!';
})
.post('/set-profile', async (ctx) => {
const session = ctx.session as NinjaSession;
const id = ctx.request['body']?.['userId'];
Expand Down
22 changes: 22 additions & 0 deletions unfollow-ninja-server/src/api/user.ts
Expand Up @@ -42,6 +42,28 @@ export function createUserRouter(dao: Dao) {
session.twitterTokenSecret = null;
ctx.status = 204;
})
.get('/enable-dms', async (ctx) => {
const session = ctx.session as NinjaSession;
const result = await dao.userEventDao.getFilteredUnfollowerEvents(session.userId, 1);
ctx.body = result;
await dao.getUserDao(session.userId).setUserParams({ dmLastEventId: result[0]?.id ?? -1 });
void dao.userEventDao.logWebEvent(
session.userId,
WebEvent.enableDms,
ctx.ip,
session.username,
(result[0]?.id ?? -1).toString()
);

ctx.status = 200;
})
.get('/disable-dms', async (ctx) => {
const session = ctx.session as NinjaSession;
await dao.getUserDao(session.userId).setUserParams({ dmLastEventId: 0 });
void dao.userEventDao.logWebEvent(session.userId, WebEvent.enableDms, ctx.ip, session.username, '0');

ctx.status = 204;
})
.put('/lang', async (ctx) => {
const session = ctx.session as NinjaSession;
const lang = ctx.request['body']?.['lang'];
Expand Down
10 changes: 1 addition & 9 deletions unfollow-ninja-server/src/dao/dao.ts
Expand Up @@ -15,6 +15,7 @@ export enum UserCategory {
dmclosed,
accountClosed,
vip,
inactive,
}

interface ICachedUsername extends Model {
Expand Down Expand Up @@ -222,13 +223,4 @@ export default class Dao {
public async deleteSession(uid: string): Promise<void> {
await this.redis.del(`session:${uid}`);
}

public async getTokenSecret(token: string): Promise<string> {
return (await this.redis.get(`tokensecret:${token}`)) || null;
}

public async setTokenSecret(token: string, secret: string): Promise<void> {
await this.redis.set(`tokensecret:${token}`, secret);
await this.redis.expire(`tokensecret:${token}`, 1200); // 20min memory (lasts <10min on twitter side)
}
}
31 changes: 27 additions & 4 deletions unfollow-ninja-server/src/dao/userDao.ts
Expand Up @@ -138,6 +138,7 @@ export default class UserDao {
added_at: parseInt(stringUserParams.added_at, 10),
lang: stringUserParams.lang as Lang,
pro: (stringUserParams.pro || '0') as '3' | '2' | '1' | '0',
dmLastEventId: parseInt(stringUserParams.dmLastEventId || '0', 10),
};
}

Expand All @@ -160,8 +161,8 @@ export default class UserDao {
return new TwitterApi({
accessToken: token,
accessSecret: tokenSecret,
appKey: process.env.DM_CONSUMER_KEY,
appSecret: process.env.DM_CONSUMER_SECRET,
appKey: process.env.CONSUMER_KEY,
appSecret: process.env.CONSUMER_SECRET,
});
}

Expand All @@ -173,15 +174,37 @@ export default class UserDao {
return new TwitterApi({
accessToken: dmToken,
accessSecret: dmTokenSecret,
appKey: process.env.DM_CONSUMER_KEY,
appSecret: process.env.DM_CONSUMER_SECRET,
appKey: process.env.CONSUMER_KEY,
appSecret: process.env.CONSUMER_SECRET,
});
}

public async getNewDmTwitterApi(): Promise<TwitterApi> {
const [dmRefreshToken] = await this.redis.hmget(`user:${this.userId}`, 'dmRefreshToken');
if (!dmRefreshToken) {
throw new Error("Tried to create a new new DM client but the user didn't have any DM credentials stored");
}
const { client, refreshToken } = await new TwitterApi({
clientId: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
}).refreshOAuth2Token(dmRefreshToken);

if (!refreshToken) {
throw new Error('Tried to refresh DM client but an error occured');
}
await this.redis.hmset(`user:${this.userId}`, { dmRefreshToken: refreshToken });

return client;
}

public async getLang(): Promise<Lang> {
return (await this.redis.hget(`user:${this.userId}`, 'lang')) as Lang;
}

public async getDmLastEventId(): Promise<number> {
return parseInt((await this.redis.hget(`user:${this.userId}`, 'dmLastEventId')) || '0', 10);
}

public async isPro(): Promise<boolean> {
return Number(await this.redis.hget(`user:${this.userId}`, 'pro')) > 0;
}
Expand Down
11 changes: 11 additions & 0 deletions unfollow-ninja-server/src/dao/userEventDao.ts
Expand Up @@ -23,6 +23,7 @@ export enum WebEvent {
registeredAsFriend,
disablePro,
disableFriendRegistration,
enableDms,
}

interface IWebEvent extends Model<InferAttributes<IWebEvent>, InferCreationAttributes<IWebEvent>> {
Expand All @@ -48,6 +49,7 @@ interface IFollowEvent extends Model<InferAttributes<IFollowEvent>, InferCreatio

export interface IUnfollowerEvent
extends Model<InferAttributes<IUnfollowerEvent>, InferCreationAttributes<IUnfollowerEvent>> {
id?: number;
userId: string;
followerId: string;
followTime: number;
Expand Down Expand Up @@ -286,6 +288,15 @@ export default class UserEventDao {
});
}

public async getFilteredUnfollowerEventsSinceId(userId: string, sinceId, limit = 500, offset = 0) {
return await this.unfollowerEvent.findAll({
where: { userId, id: { [Op.gt]: sinceId }, [Op.or]: [{ deleted: false }, { isSecondCheck: true }] },
order: [['id', 'desc']],
limit,
offset,
});
}

public async getFilteredMutualUnfollowerEvents(userId: string, limit = 500, offset = 0) {
return await this.unfollowerEvent.findAll({
where: { userId, following: true, [Op.or]: [{ deleted: false }, { isSecondCheck: true }] },
Expand Down
4 changes: 2 additions & 2 deletions unfollow-ninja-server/src/tasks/notifyUser.ts
Expand Up @@ -199,6 +199,7 @@ export default class extends Task {
metrics.increment('notifyUser.count');

if (realUnfollowersInfo.length > 0) {
/*
const message = this.generateMessage(realUnfollowersInfo, await userDao.getLang(), leftovers.length);
await this.dao.userEventDao.logNotificationEvent(
Expand All @@ -207,15 +208,14 @@ export default class extends Task {
await userDao.getDmId(),
message
);
/*
const dmTwit = await userDao.getDmTwitterApi();
logger.info('sending a DM to @%s', username);
await dmTwit.v2
.sendDmToParticipant(userId, { text: message })
.catch((err) => this.manageTwitterErrors(err, username, userId));
*/
metrics.increment('notifyUser.dmsSent');
*/
metrics.increment('notifyUser.nbUnfollowers', realUnfollowersInfo.length + leftovers.length);
}
}
Expand Down

0 comments on commit 0702ad6

Please sign in to comment.