• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

hicommonwealth / commonwealth / 13207538896

07 Feb 2025 08:28PM UTC coverage: 46.309% (-0.05%) from 46.356%
13207538896

push

github

web-flow
Merge pull request #10813 from hicommonwealth/rotorsoft/9991-contest-notifications

Route contest notifications

1359 of 3292 branches covered (41.28%)

Branch coverage included in aggregate %.

49 of 86 new or added lines in 11 files covered. (56.98%)

5 existing lines in 3 files now uncovered.

2643 of 5350 relevant lines covered (49.4%)

36.32 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

67.33
/libs/model/src/utils/utils.ts
1
import { blobStorage, logger } from '@hicommonwealth/core';
2
import { isEvmAddress } from '@hicommonwealth/evm-protocols';
3
import { EventPairs } from '@hicommonwealth/schemas';
4
import {
5
  getThreadUrl,
6
  safeTruncateBody,
7
  type AbiType,
8
} from '@hicommonwealth/shared';
9
import { NeynarAPIClient } from '@neynar/nodejs-sdk';
10
import { createHash } from 'crypto';
11
import { hasher } from 'node-object-hash';
12
import {
13
  Model,
14
  ModelStatic,
15
  QueryTypes,
16
  Sequelize,
17
  Transaction,
18
} from 'sequelize';
19
import { v4 as uuidv4 } from 'uuid';
20
import { config } from '../config';
21
import type { OutboxAttributes } from '../models/outbox';
22

23
const log = logger(import.meta);
38✔
24

25
export function hashAbi(abi: AbiType): string {
26
  const hashInstance = hasher({
×
27
    coerce: true,
28
    sort: true,
29
    trim: true,
30
    alg: 'sha256',
31
    enc: 'hex',
32
  });
33
  return hashInstance.hash(abi);
×
34
}
35

36
/**
37
 * This functions takes either a new domain record or a pre-formatted event and inserts it into the Outbox. For core
38
 * domain events (e.g. new thread, new comment, etc.), the event_payload should be the complete domain record. The point
39
 * of this is that the emitter of a core domain event should never have to format the record itself. This
40
 * utility function centralizes event emission so that if any changes are required to the Outbox table or emission of
41
 * a specific event, this function can be updated without having to update the emitter code.
42
 */
43
export async function emitEvent(
44
  outbox: ModelStatic<Model<OutboxAttributes>>,
45
  values: Array<EventPairs>,
46
  transaction?: Transaction | null,
47
) {
48
  const records: Array<EventPairs> = [];
96✔
49
  for (const event of values) {
96✔
50
    if (!config.OUTBOX.BLACKLISTED_EVENTS.includes(event.event_name)) {
97!
51
      records.push(event);
97✔
52
    } else {
53
      log.warn(
×
54
        `Event not inserted into outbox! ` +
55
          `The event "${event.event_name}" is blacklisted.
56
          Remove it from BLACKLISTED_EVENTS env in order to allow emitting this event.`,
57
        {
58
          event_name: event.event_name,
59
          allowed_events: config.OUTBOX.BLACKLISTED_EVENTS,
60
        },
61
      );
62
    }
63
  }
64

65
  if (records.length > 0) {
96✔
66
    await outbox.bulkCreate(values, { transaction });
92✔
67
  }
68
}
69

70
export function buildThreadContentUrl(communityId: string, threadId: number) {
71
  const fullContentUrl = getThreadUrl({
3✔
72
    chain: communityId,
73
    id: threadId,
74
  });
75
  // content url only contains path
76
  return new URL(fullContentUrl).pathname;
3✔
77
}
78

79
// returns community ID and thread ID from content url
80
export function decodeThreadContentUrl(contentUrl: string): {
81
  communityId: string | null;
82
  threadId: number | null;
83
  isFarcaster: boolean;
84
} {
85
  if (contentUrl.startsWith('/farcaster/')) {
3!
86
    return {
×
87
      communityId: null,
88
      threadId: null,
89
      isFarcaster: true,
90
    };
91
  }
92
  if (!contentUrl.includes('/discussion/')) {
3!
93
    throw new Error(`invalid content url: ${contentUrl}`);
×
94
  }
95
  const [communityId, threadId] = contentUrl
3✔
96
    .split('/discussion/')
97
    .map((part) => part.replaceAll('/', ''));
6✔
98
  return {
3✔
99
    communityId,
100
    threadId: parseInt(threadId, 10),
101
    isFarcaster: false,
102
  };
103
}
104

105
/**
106
 * Checks whether two Ethereum addresses are equal. Throws if a provided string is not a valid EVM address.
107
 * Address comparison is done in lowercase to ensure case insensitivity.
108
 * @param address1 - The first Ethereum address.
109
 * @param address2 - The second Ethereum address.
110
 * @returns True if the strings are equal, valid EVM addresses - false otherwise.
111
 */
112
export function equalEvmAddresses(
113
  address1: string | unknown,
114
  address2: string | unknown,
115
): boolean {
116
  const isRealAddress = (address: string | unknown) => {
1✔
117
    if (!address || typeof address !== 'string' || !isEvmAddress(address)) {
2!
118
      throw new Error(`Invalid address ${address}`);
×
119
    }
120
    return address;
2✔
121
  };
122

123
  const validAddress1 = isRealAddress(address1);
1✔
124
  const validAddress2 = isRealAddress(address2);
1✔
125

126
  // Convert addresses to lowercase and compare
127
  const normalizedAddress1 = validAddress1.toLowerCase();
1✔
128
  const normalizedAddress2 = validAddress2.toLowerCase();
1✔
129

130
  return normalizedAddress1 === normalizedAddress2;
1✔
131
}
132

133
/**
134
 * Returns all contest managers associated with a thread by topic and community
135
 * @param sequelize - The sequelize instance
136
 * @param topicId - The topic ID of the thread
137
 * @param communityId - the community ID of the thread
138
 * @returns array of contest manager
139
 */
140
export async function getThreadContestManagers(
141
  sequelize: Sequelize,
142
  topicId: number,
143
  communityId: string,
144
): Promise<
145
  {
146
    contest_address: string;
147
  }[]
148
> {
149
  const contestManagers = await sequelize.query<{
36✔
150
    contest_address: string;
151
  }>(
152
    `
153
        SELECT cm.contest_address, cm.cancelled, cm.ended
154
        FROM "Communities" c
155
                 JOIN "ContestManagers" cm ON cm.community_id = c.id
156
        WHERE cm.topic_id = :topic_id
157
          AND cm.community_id = :community_id
158
          AND cm.cancelled IS NOT TRUE
159
          AND cm.ended IS NOT TRUE
160
    `,
161
    {
162
      type: QueryTypes.SELECT,
163
      replacements: {
164
        topic_id: topicId,
165
        community_id: communityId,
166
      },
167
    },
168
  );
169
  return contestManagers;
36✔
170
}
171

172
export function removeUndefined(
173
  obj: Record<string, string | number | undefined>,
174
) {
175
  const result: Record<string, string | number | undefined> = {};
×
176

177
  Object.keys(obj).forEach((key) => {
×
178
    if (obj[key] !== undefined) {
×
179
      result[key as string] = obj[key];
×
180
    }
181
  });
182

183
  return result;
×
184
}
185

186
const alchemyUrlPattern = /^https:\/\/[a-z]+-[a-z]+\.g\.alchemy\.com\/v2\//;
38✔
187

188
export function buildChainNodeUrl(url: string, privacy: 'private' | 'public') {
189
  if (url === '') return url;
99!
190

191
  if (alchemyUrlPattern.test(url)) {
99✔
192
    const [baseUrl, key] = url.split('/v2/');
48✔
193
    if (key === config.ALCHEMY.APP_KEYS.PRIVATE && privacy !== 'private')
48!
194
      return `${baseUrl}/v2/${config.ALCHEMY.APP_KEYS.PUBLIC}`;
48✔
195
    else if (key === config.ALCHEMY.APP_KEYS.PUBLIC && privacy !== 'public')
56✔
196
      return `${baseUrl}/v2/${config.ALCHEMY.APP_KEYS.PRIVATE}`;
44✔
197
    else if (key === '')
198
      return (
38✔
199
        url +
200
        (privacy === 'private'
38✔
201
          ? config.ALCHEMY.APP_KEYS.PRIVATE
202
          : config.ALCHEMY.APP_KEYS.PUBLIC)
203
      );
204
  }
205
  return url;
57✔
206
}
207

208
export function getChainNodeUrl({
209
  url,
210
  private_url,
211
}: {
212
  url: string;
213
  private_url?: string | null | undefined;
214
}) {
215
  if (!private_url || private_url === '')
8!
216
    return buildChainNodeUrl(url, 'public');
8✔
UNCOV
217
  return buildChainNodeUrl(private_url, 'private');
×
218
}
219

220
export const R2_ADAPTER_KEY = 'blobStorageFactory.R2BlobStorage.Main';
38✔
221

222
/**
223
 * Limits content in the Threads.body and Comments.text columns to 2k characters (2kB)
224
 * Anything over this character limit is stored in Cloudflare R2.
225
 * 55% of threads and 90% of comments are shorter than this.
226
 * Anything over 2kB is TOASTed by Postgres so this limit prevents TOAST.
227
 */
228
const CONTENT_CHAR_LIMIT = 2_000;
38✔
229

230
/**
231
 * Uploads content to the appropriate R2 bucket if the content exceeds the
232
 * preview limit (CONTENT_CHAR_LIMIT),
233
 */
234
export async function uploadIfLarge(
235
  type: 'threads' | 'comments',
236
  content: string,
237
): Promise<{
238
  contentUrl: string | null;
239
  truncatedBody: string | null;
240
}> {
241
  if (content.length > CONTENT_CHAR_LIMIT) {
33✔
242
    const { url } = await blobStorage({
5✔
243
      key: R2_ADAPTER_KEY,
244
    }).upload({
245
      key: `${uuidv4()}.md`,
246
      bucket: type,
247
      content: content,
248
      contentType: 'text/markdown',
249
    });
250
    return { contentUrl: url, truncatedBody: safeTruncateBody(content, 500) };
5✔
251
  } else return { contentUrl: null, truncatedBody: null };
28✔
252
}
253

254
export function getSaltedApiKeyHash(apiKey: string, salt: string): string {
255
  return createHash('sha256')
×
256
    .update(apiKey + salt)
257
    .digest('hex');
258
}
259

260
export function buildApiKeySaltCacheKey(address: string) {
261
  return `salt_${address.toLowerCase()}`;
×
262
}
263

264
export async function publishCast(
265
  replyCastHash: string,
266
  messageBuilder: ({ username }: { username: string }) => string,
267
  options?: { embed: string },
268
) {
269
  const client = new NeynarAPIClient(config.CONTESTS.NEYNAR_API_KEY!);
×
270
  try {
×
271
    const {
272
      result: { casts },
273
    } = await client.fetchBulkCasts([replyCastHash]);
×
274
    const username = casts[0].author.username!;
×
275
    await client.publishCast(
×
276
      config.CONTESTS.NEYNAR_BOT_UUID!,
277
      messageBuilder({ username }),
278
      {
279
        replyTo: replyCastHash,
280
        embeds: options?.embed ? [{ url: options.embed }] : undefined,
×
281
      },
282
    );
283
    log.info(`FC bot published reply to ${replyCastHash}`);
×
284
  } catch (err) {
285
    log.error(`Failed to post as FC bot`, err as Error);
×
286
  }
287
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc