@consensys/web3tickets-snap
Version:
Metamask Snap to get ticket message notifications directly in the MetaMask browser extension!
425 lines (366 loc) • 15.3 kB
text/typescript
import { OnRpcRequestHandler, OnCronjobHandler } from '@metamask/snaps-types';
import { panel, text, heading } from '@metamask/snaps-ui';
import { get_user_tickets, get_ticket_comments, update_ticket, set_snap_dialog } from '../utils/backend_functions';
import { createInterface, goBack, refreshHomepage, showConfirmationMessage, showFailedMessage, showLoadingSpinner, showSettings, showTicket, showTicketList } from './ui';
import { OnUserInputHandler, UserInputEventType, button, divider, spinner } from '@metamask/snaps-sdk';
const AUTHORIZED_ORIGIN_LOCAL = 'http://localhost:8000';
const AUTHORIZED_ORIGIN_PROD = 'https://tickets.metamask.io';
const ZD_BOT_SENDER_ID = 397243412931;
let interfaceId = null;
export const onHomePage = async () => {
const state = await getSnapState();
const address = state?.address as string;
const apiKey = state?.apiKey as string;
interfaceId = await createInterface( address, apiKey);
return { id: interfaceId };
};
// dialog can be true or false, true == MM snaps notifications chosen
// false == browser notifications chosen
const updateNotificationSettings = async (id, dialog) => {
const state = await getSnapState();
await showLoadingSpinner(id, 'notification-settings');
const success = await set_snap_dialog(dialog, state.address, state.apiKey);
const notification_type = dialog === true ? 'Metamask Snap notifications' : 'browser native notifications';
if (success) {
await showConfirmationMessage(id, `Notifications are now set to ${notification_type}.`);
}
else {
await showFailedMessage(id, 'Failed to save notifications settings. Please try again...');
}
// dialog needs to be string in state
const new_dialog = dialog === true ? 'true' : 'false';
await setSnapState(state.apiKey as string, state.address as string, state.ticketUpdates, new_dialog, state.apiExpiry as string, state.expiryNotificationsCount, state.lastAlertTime, state.cachedTicketData);
}
export const onUserInput: OnUserInputHandler = async ({ id, event }) => {
if (event.type === UserInputEventType.ButtonClickEvent) {
if (event.name.startsWith('showTicket-')) {
const ticketId = event.name.split("-")[1]
try {
await showLoadingSpinner(id, 'loading-ticket');
await snap.request({
method: 'snap_updateInterface',
params: {
id,
ui: await showTicket(ticketId),
},
});
}
catch (error) {
console.log(error);
}
}
else if (event.name === 'go-back') {
await showLoadingSpinner(id, 'loading-goback');
await goBack(id);
}
else if (event.name === 'notification-settings') {
await showSettings(id);
}
else if (event.name === 'message-sent-ok-button') {
await showLoadingSpinner(id, 'loading-homepage');
await refreshHomepage(id);
}
else if (event.name === 'notif-choice-snap'){
await updateNotificationSettings(id, true);
}
else if (event.name === 'notif-choice-browser'){
await updateNotificationSettings(id, false);
}
}
if (
event.type === UserInputEventType.FormSubmitEvent &&
event.name.startsWith('sendcomment-')
) {
await showLoadingSpinner(id, 'loading-comment');
const ticketId = event.name.split('-')[1];
const comment = event.value['sendcomment-input'];
const state = await getSnapState();
const address = state?.address as string;
const apiKey = state?.apiKey as string;
const updated = await update_ticket(ticketId, comment, address, apiKey);
if (updated === true) {
await showConfirmationMessage(id, 'Our support team has received your comment.');
}
else {
console.log('Ticket update failed');
await showFailedMessage(id, 'The comment could not be sent. Please try again or use the [dashboard](https://tickets.metamask.io) to update your tickets.');
}
}
};
export const getSnapState = async () => {
const state = await snap.request({
method: 'snap_manageState',
params: {
operation: 'get',
},
});
return state;
};
export const setSnapState = async (apiKey: string | null, address: string | null, ticketUpdates: any, dialog: string | null,
apiExpiry: string | null, expiryNotificationsCount: any, lastAlertTime: any, cachedTicketData: any) => {
return snap.request({
method: 'snap_manageState',
params: {
operation: 'update',
newState: {
apiKey,
address,
ticketUpdates,
dialog,
apiExpiry,
expiryNotificationsCount,
lastAlertTime,
cachedTicketData
},
},
});
};
// will not catch the following scenario:
// agent posts an update and then within 30 seconds also posts an internal comment
//
// compares current latest comment id on each ticket to the previous latest comment id on the same ticket
// if there's a change -> there was an update
const compareStates = (prev_ticketUpdates: any, current_ticketUpdates: any) => {
let updatedTicketIds : any = [];
for (const { ticketId, lastCommentId, isLastCommentPublic, senderId } of current_ticketUpdates) {
// only consider public messages sent by the agent
if (senderId !== ZD_BOT_SENDER_ID && isLastCommentPublic) {
for (const { ticketId: prev_ticketId, lastCommentId: prev_lastCommentId} of prev_ticketUpdates) {
if (prev_ticketId === ticketId && prev_lastCommentId !== lastCommentId) {
updatedTicketIds.push(ticketId);
}
}
}
}
return updatedTicketIds;
}
// checks for ticket updates and returns a list of ticket IDs that have received an
// update since the last cronjob run - which occurs every 30 seconds
const checkTicketUpdates = async () => {
const state = await getSnapState();
const address = state?.address as string
const apiKey = state?.apiKey as string
const dialog = state?.dialog as string
const apiExpiry = state?.apiExpiry as string
// if address is empty exit the function
if (!state || !address || !apiKey) {
console.log('Address or api key not present in snap state yet.');
return;
}
let ticketUpdates: any = [];
const prev_ticketUpdates = state?.ticketUpdates;
const expiryNotificationsCount = state?.expiryNotificationsCount as number;
const lastAlertTime = state?.lastAlertTime as string;
const cachedTicketData = state?.cachedTicketData;
try {
const json : any = await get_user_tickets(address, apiKey);
if (json && json.hasOwnProperty('count')) {
const ticket_count = json['count'];
if (ticket_count > 0) {
for (let i = 0; i < ticket_count; i++) {
const lastCommentId = json['rows'][i]['ticket']['last_comment']['id'];
const isLastCommentPublic = json['rows'][i]['ticket']['last_comment']['public'];
const ticketId = json['rows'][i]['ticket']['id'];
const senderId = json['rows'][i]['ticket']['last_comment']['author_id'];
ticketUpdates.push({ ticketId, lastCommentId, isLastCommentPublic, senderId });
}
}
else {
console.log('There are no tickets created for this public address yet.');
}
}
else {
throw new Error('Failed to fetch tickets. Response does not have the "count" property.');
}
} catch (error) {
console.error('Error fetching user tickets:', error);
}
let updatedTicketIds: any[] = [];
// if it's the first iteration of the cronjob just initialise the state
if (!prev_ticketUpdates) {
console.log('Initialising state...');
await setSnapState(apiKey, address, ticketUpdates, dialog, apiExpiry, expiryNotificationsCount, lastAlertTime, cachedTicketData);
}
else {
updatedTicketIds = compareStates(prev_ticketUpdates, ticketUpdates);
if (updatedTicketIds?.length > 0) {
console.log('Found updates for the following tickets: ', updatedTicketIds);
}
await setSnapState(apiKey, address, ticketUpdates, dialog, apiExpiry, expiryNotificationsCount, lastAlertTime, cachedTicketData);
}
return updatedTicketIds;
}
async function parseTicketComments(ticketId: any) {
const state = await getSnapState();
const address = state?.address as string
const apiKey = state?.apiKey as string
// exit if needed variables or state are not available
if (!state || !ticketId || !address || !apiKey) return 'Could not fetch comments. Please login to https://tickets.metamask.io/ to see your personal dashboard with all tickets open for your ethereum account address';
let formatted_comments = `Login to https://tickets.metamask.io/ to see your personal dashboard with all tickets open for your ethereum account address. \n\n`;
await get_ticket_comments(ticketId, address, apiKey).then((json: any) => {
if (json.length > 0) {
for (let i = 0; i < json.length; i++){
const comment = json[i]['body'];
let sender = (json[i]['via']['channel'] == 'api' || json[i]['via']['channel'] == 'email') ? '**You**' : '**Agent**'
if (i === 0) {
sender = '**Description**';
}
formatted_comments += `${sender}: ${comment}\n\n______________________\n\n`;
}
}
})
return formatted_comments;
}
// updates a ticket with user's new comment, from the notification dialog box
async function updateTicket(ticketId: any, user_comment: any) {
const state = await getSnapState();
// exit if state is not available
if (!state) return -1;
const address = state?.address as string
const apiKey = state?.apiKey as string
const update_result = await update_ticket(ticketId, user_comment, address, apiKey);
return update_result;
}
// notifies the user that a specific ticket has been updated
async function notifyUser(ticketId : any, state: any) {
const formatted_comments = await parseTicketComments(ticketId);
// in Metamask notification
await snap.request({
method: 'snap_notify',
params: {
type: 'inApp',
message: `There is an update on your ticket #${ticketId} !`
},
});
// if user opted for dialog box notifications
if (state?.dialog === 'true') {
console.log('notifying via dialog')
const user_comment = await snap.request({
method: 'snap_dialog',
params: {
type: 'prompt',
content: panel([
heading(`Conversation ID: ${ticketId}`),
text(formatted_comments)
]),
placeholder: 'Enter response to message here...',
},
})
if (user_comment) {
const update_result = await updateTicket(ticketId, user_comment);
if (update_result == -1) {
await snap.request({
method: 'snap_dialog',
params: {
type: 'alert',
content: panel([
heading(` Comment could not be submitted.`),
text(`Please login to your personal dashboard at https://tickets.metamask.io/ and try again.`)
])
},
})
}
}
}
// if user opted for browser notifications
else {
console.log('notifying via native browser notification')
await snap.request({
method: 'snap_notify',
params: {
type: 'native',
message: `An agent has replied on your ticket #${ticketId} !`
},
});
}
}
//Cron Jobs run every 30 seconds or so currently, polling for new messages
export const onCronjob: OnCronjobHandler = async ({ request }) => {
switch (request.method) {
case 'fireCronjob':
const { locked } = await snap.request({
method: 'snap_getClientStatus'
});
// only continue polling if wallet is unlocked
if (locked) return;
const state = await getSnapState();
// for production:
const apiExpiry = state?.apiExpiry as string;
const lastAlertTime = state?.lastAlertTime as string;
// for debugging:
// console.log(state);
// const apiExpiry = "Mon Dec 21 2023 13:18:18 GMT+0200";
// const lastAlertTime = "Mon Dec 21 2023 14:18:18 GMT+0200";
const currentTime = new Date();
const alertsInterval = 14400000;
const timeSinceLastAlert = lastAlertTime ? currentTime.getTime() - new Date(lastAlertTime).getTime() : alertsInterval;
const alertsCount = (state?.expiryNotificationsCount ?? 0) as number;
// if api key has expired, notify the user and don't try to perform any other requests
// every 4 hours, up to 3 alerts
if (apiExpiry && timeSinceLastAlert >= alertsInterval && alertsCount < 3 && new Date(apiExpiry) < currentTime) {
await snap.request({
method: 'snap_dialog',
params: {
type: 'alert',
content: panel([
heading(` Your authentication key has expired !`),
text(`Please login to https://tickets.metamask.io/ and sign-in again. Metamask support notifications will not be functional until you do so.`)
])
},
})
await setSnapState(state?.apiKey as string, state?.address as string, state?.ticketUpdates, state?.dialog as string, state?.apiExpiry as string, alertsCount + 1, currentTime as unknown as string, state?.cachedTicketData);
}
else {
// only go on if api key is not expired i.e. alertsCount === 0
if (alertsCount === 0) {
const updatedTickets = await checkTicketUpdates();
const fireAlerts = updatedTickets && updatedTickets.length > 0;
// notify the user for each updated ticket
if (fireAlerts) {
for (const ticketId of updatedTickets) {
await notifyUser(ticketId, state);
}
}
}
}
break;
default:
throw new Error('Method not found.');
}
};
/**
* Handle incoming JSON-RPC requests, sent through `wallet_invokeSnap`.
*
* @param args - The request handler args as object.
* @param args.request - A validated JSON-RPC request object.
* @returns The result of `snap_dialog`.
* @throws If the request method is not valid for this snap.
*/
export const onRpcRequest: OnRpcRequestHandler = async ({ origin, request }) => {
// Origin constants need to be updated in prod
if (origin !== AUTHORIZED_ORIGIN_LOCAL && origin !== AUTHORIZED_ORIGIN_PROD) {
throw new Error('Only Metamask domains allowed.');
}
switch (request.method) {
// this is called by the dashboard when first logging in or changing notification settings
case 'set_snap_state':
if (
(request.params &&
'apiKey' in request.params &&
typeof request.params.apiKey === 'string') &&
request.params &&
'address' in request.params &&
typeof request.params.address === 'string' &&
'dialog' in request.params &&
typeof request.params.dialog === 'string' &&
'apiExpiry' in request.params &&
typeof request.params.apiExpiry === 'string'
) {
await setSnapState(request.params.apiKey, request.params.address, undefined, request.params.dialog, request.params.apiExpiry, undefined, undefined, undefined);
return true;
}
throw new Error(`Must provide params.apiKey and params.address and params.dialog and params.apiExpiry. Received ${request.params}`);
default:
throw new Error('Method not found.');
}
};