diff --git a/changelog.d/927.bugfix b/changelog.d/927.bugfix new file mode 100644 index 00000000..84e579f3 --- /dev/null +++ b/changelog.d/927.bugfix @@ -0,0 +1 @@ +Fix a few bugs introduced in challenge hound support. diff --git a/docs/setup/challengehound.md b/docs/setup/challengehound.md index 2995b006..ff5be95d 100644 --- a/docs/setup/challengehound.md +++ b/docs/setup/challengehound.md @@ -5,23 +5,14 @@ into Matrix. ### Getting the API secret. -Unfortunately, there is no way to directly request a persistent Challenge Hound API token. The -only way to authenticate with the service at present is to login with an email address and receive -a magic token in an email. This is not something Hookshot has the capability to do on it's own. - -In order to extract the token for use with the bridge, login to Challenge Hound. Once logged in, -please locate the local storage via the devtools of your browser. Inside you will find a `ch:user` -entry with a `token` value. That value should be used as the secret for your Hookshot config. +You will need to email ChallengeHound support for an API token. They seem happy to provide one +as long as you are an admin of a challenge. See [this support article](https://support.challengehound.com/article/69-does-challenge-hound-have-an-api) ```yaml challengeHound: token: ``` -This token tends to expire roughly once a month, and for the moment you'll need to manually -replace it. You can also ask Challenge Hound's support for an API key, although this has not -been tested. - ## Usage You can add a new challenge hound challenge by command: diff --git a/src/Connections/HoundConnection.ts b/src/Connections/HoundConnection.ts index f56cb45b..a70c2e44 100644 --- a/src/Connections/HoundConnection.ts +++ b/src/Connections/HoundConnection.ts @@ -4,7 +4,8 @@ import { BaseConnection } from "./BaseConnection"; import { IConnection, IConnectionState } from "."; import { Connection, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection"; import { CommandError } from "../errors"; - +import { IBridgeStorageProvider } from "../Stores/StorageProvider"; +import { Logger } from "matrix-appservice-bridge"; export interface HoundConnectionState extends IConnectionState { challengeId: string; } @@ -14,20 +15,44 @@ export interface HoundPayload { challengeId: string, } +/** + * @url https://documenter.getpostman.com/view/22349866/UzXLzJUV#0913e0b9-9cb5-440e-9d8d-bf6430285ee9 + */ export interface HoundActivity { - id: string; - distance: number; // in meters - duration: number; - elevation: number; - createdAt: string; - activityType: string; - activityName: string; - user: { - id: string; - fullname: string; - fname: string; - lname: string; - } + userId: string, + activityId: string, + participant: string, + /** + * @example "07/26/2022" + */ + date: string, + /** + * @example "2022-07-26T13:49:22Z" + */ + datetime: string, + name: string, + type: string, + /** + * @example strava + */ + app: string, + durationSeconds: number, + /** + * @example "1.39" + */ + distanceKilometers: string, + /** + * @example "0.86" + */ + distanceMiles: string, + /** + * @example "0.86" + */ + elevationMeters: string, + /** + * @example "0.86" + */ + elevationFeet: string, } export interface IChallenge { @@ -76,6 +101,7 @@ function getEmojiForType(type: string) { } } +const log = new Logger("HoundConnection"); const md = markdownit(); @Connection export class HoundConnection extends BaseConnection implements IConnection { @@ -95,12 +121,12 @@ export class HoundConnection extends BaseConnection implements IConnection { public static validateState(data: Record): HoundConnectionState { // Convert URL to ID. - if (!data.challengeId && data.url && data.url === "string") { + if (!data.challengeId && data.url && typeof data.url === "string") { data.challengeId = this.getIdFromURL(data.url); } // Test for v1 uuid. - if (!data.challengeId || typeof data.challengeId !== "string" || /^\w{8}(?:-\w{4}){3}-\w{12}$/.test(data.challengeId)) { + if (!data.challengeId || typeof data.challengeId !== "string" || !/^\w{8}(?:-\w{4}){3}-\w{12}$/.test(data.challengeId)) { throw Error('Missing or invalid id'); } @@ -109,14 +135,14 @@ export class HoundConnection extends BaseConnection implements IConnection { } } - public static createConnectionForState(roomId: string, event: StateEvent>, {config, intent}: InstantiateConnectionOpts) { + public static createConnectionForState(roomId: string, event: StateEvent>, {config, intent, storage}: InstantiateConnectionOpts) { if (!config.challengeHound) { throw Error('Challenge hound is not configured'); } - return new HoundConnection(roomId, event.stateKey, this.validateState(event.content), intent); + return new HoundConnection(roomId, event.stateKey, this.validateState(event.content), intent, storage); } - static async provisionConnection(roomId: string, _userId: string, data: Record = {}, {intent, config}: ProvisionConnectionOpts) { + static async provisionConnection(roomId: string, _userId: string, data: Record = {}, {intent, config, storage}: ProvisionConnectionOpts) { if (!config.challengeHound) { throw Error('Challenge hound is not configured'); } @@ -127,7 +153,7 @@ export class HoundConnection extends BaseConnection implements IConnection { throw new CommandError(`Fetch failed, status ${statusDataRequest.status}`, "Challenge could not be found. Is it active?"); } const { challengeName } = await statusDataRequest.json() as {challengeName: string}; - const connection = new HoundConnection(roomId, validState.challengeId, validState, intent); + const connection = new HoundConnection(roomId, validState.challengeId, validState, intent, storage); await intent.underlyingClient.sendStateEvent(roomId, HoundConnection.CanonicalEventType, validState.challengeId, validState); return { connection, @@ -140,7 +166,8 @@ export class HoundConnection extends BaseConnection implements IConnection { roomId: string, stateKey: string, private state: HoundConnectionState, - private readonly intent: Intent) { + private readonly intent: Intent, + private readonly storage: IBridgeStorageProvider) { super(roomId, stateKey, HoundConnection.CanonicalEventType) } @@ -156,25 +183,41 @@ export class HoundConnection extends BaseConnection implements IConnection { return this.state.priority || super.priority; } - public async handleNewActivity(payload: HoundActivity) { - const distance = `${(payload.distance / 1000).toFixed(2)}km`; - const emoji = getEmojiForType(payload.activityType); - const body = `🎉 **${payload.user.fullname}** completed a ${distance} ${emoji} ${payload.activityType} (${payload.activityName})`; - const content: any = { + public async handleNewActivity(activity: HoundActivity) { + log.info(`New activity recorded ${activity.activityId}`); + const existingActivityEventId = await this.storage.getHoundActivity(this.challengeId, activity.activityId); + const distance = parseFloat(activity.distanceKilometers); + const distanceUnits = `${(distance).toFixed(2)}km`; + const emoji = getEmojiForType(activity.type); + const body = `🎉 **${activity.participant}** completed a ${distanceUnits} ${emoji} ${activity.type} (${activity.name})`; + let content: any = { body, format: "org.matrix.custom.html", formatted_body: md.renderInline(body), }; content["msgtype"] = "m.notice"; - content["uk.half-shot.matrix-challenger.activity.id"] = payload.id; - content["uk.half-shot.matrix-challenger.activity.distance"] = Math.round(payload.distance); - content["uk.half-shot.matrix-challenger.activity.elevation"] = Math.round(payload.elevation); - content["uk.half-shot.matrix-challenger.activity.duration"] = Math.round(payload.duration); + content["uk.half-shot.matrix-challenger.activity.id"] = activity.activityId; + content["uk.half-shot.matrix-challenger.activity.distance"] = Math.round(distance * 1000); + content["uk.half-shot.matrix-challenger.activity.elevation"] = Math.round(parseFloat(activity.elevationMeters)); + content["uk.half-shot.matrix-challenger.activity.duration"] = Math.round(activity.durationSeconds); content["uk.half-shot.matrix-challenger.activity.user"] = { - "name": payload.user.fullname, - id: payload.user.id, + "name": activity.participant, + id: activity.userId, }; - await this.intent.underlyingClient.sendMessage(this.roomId, content); + if (existingActivityEventId) { + log.debug(`Updating existing activity ${activity.activityId} ${existingActivityEventId}`); + content = { + body: `* ${content.body}`, + msgtype: "m.notice", + "m.new_content": content, + "m.relates_to": { + "event_id": existingActivityEventId, + "rel_type": "m.replace" + }, + }; + } + const eventId = await this.intent.underlyingClient.sendMessage(this.roomId, content); + await this.storage.storeHoundActivityEvent(this.challengeId, activity.activityId, eventId); } public toString() { diff --git a/src/Stores/MemoryStorageProvider.ts b/src/Stores/MemoryStorageProvider.ts index ef514402..a6336e59 100644 --- a/src/Stores/MemoryStorageProvider.ts +++ b/src/Stores/MemoryStorageProvider.ts @@ -15,6 +15,7 @@ export class MemoryStorageProvider extends MSP implements IBridgeStorageProvider private gitlabDiscussionThreads = new Map(); private feedGuids = new Map>(); private houndActivityIds = new Map>(); + private houndActivityIdToEvent = new Map(); constructor() { super(); @@ -110,19 +111,27 @@ export class MemoryStorageProvider extends MSP implements IBridgeStorageProvider this.gitlabDiscussionThreads.set(connectionId, value); } - async storeHoundActivity(url: string, ...ids: string[]): Promise { - let set = this.houndActivityIds.get(url); + async storeHoundActivity(challengeId: string, ...activityIds: string[]): Promise { + let set = this.houndActivityIds.get(challengeId); if (!set) { set = [] - this.houndActivityIds.set(url, set); + this.houndActivityIds.set(challengeId, set); } - set.unshift(...ids); + set.unshift(...activityIds); while (set.length > MAX_FEED_ITEMS) { set.pop(); } } - async hasSeenHoundActivity(url: string, ...ids: string[]): Promise { - const existing = this.houndActivityIds.get(url); - return existing ? ids.filter((existingGuid) => existing.includes(existingGuid)) : []; + async hasSeenHoundActivity(challengeId: string, ...activityIds: string[]): Promise { + const existing = this.houndActivityIds.get(challengeId); + return existing ? activityIds.filter((existingGuid) => existing.includes(existingGuid)) : []; + } + + public async storeHoundActivityEvent(challengeId: string, activityId: string, eventId: string): Promise { + this.houndActivityIdToEvent.set(`${challengeId}.${activityId}`, eventId); + } + + public async getHoundActivity(challengeId: string, activityId: string): Promise { + return this.houndActivityIdToEvent.get(`${challengeId}.${activityId}`) ?? null; } } diff --git a/src/Stores/RedisStorageProvider.ts b/src/Stores/RedisStorageProvider.ts index 6e2e5122..657b0b53 100644 --- a/src/Stores/RedisStorageProvider.ts +++ b/src/Stores/RedisStorageProvider.ts @@ -23,13 +23,15 @@ const STORED_FILES_EXPIRE_AFTER = 24 * 60 * 60; // 24 hours const COMPLETED_TRANSACTIONS_EXPIRE_AFTER = 24 * 60 * 60; // 24 hours const ISSUES_EXPIRE_AFTER = 7 * 24 * 60 * 60; // 7 days const ISSUES_LAST_COMMENT_EXPIRE_AFTER = 14 * 24 * 60 * 60; // 7 days +const HOUND_EVENT_CACHE = 90 * 24 * 60 * 60; // 30 days const WIDGET_TOKENS = "widgets.tokens."; const WIDGET_USER_TOKENS = "widgets.user-tokens."; const FEED_GUIDS = "feeds.guids."; -const HOUND_IDS = "feeds.guids."; +const HOUND_GUIDS = "hound.guids."; +const HOUND_EVENTS = "hound.events."; const log = new Logger("RedisASProvider"); @@ -242,24 +244,36 @@ export class RedisStorageProvider extends RedisStorageContextualProvider impleme return guids.filter((_guid, index) => res[index][1] !== null); } - public async storeHoundActivity(url: string, ...guids: string[]): Promise { - const feedKey = `${HOUND_IDS}${url}`; - await this.redis.lpush(feedKey, ...guids); - await this.redis.ltrim(feedKey, 0, MAX_FEED_ITEMS); + public async storeHoundActivity(challengeId: string, ...activityHashes: string[]): Promise { + const key = `${HOUND_GUIDS}${challengeId}`; + await this.redis.lpush(key, ...activityHashes); + await this.redis.ltrim(key, 0, MAX_FEED_ITEMS); } - public async hasSeenHoundActivity(url: string, ...guids: string[]): Promise { + public async hasSeenHoundActivity(challengeId: string, ...activityHashes: string[]): Promise { let multi = this.redis.multi(); - const feedKey = `${HOUND_IDS}${url}`; + const key = `${HOUND_GUIDS}${challengeId}`; - for (const guid of guids) { - multi = multi.lpos(feedKey, guid); + for (const guid of activityHashes) { + multi = multi.lpos(key, guid); } const res = await multi.exec(); if (res === null) { // Just assume we've seen none. return []; } - return guids.filter((_guid, index) => res[index][1] !== null); + return activityHashes.filter((_guid, index) => res[index][1] !== null); + } + + public async storeHoundActivityEvent(challengeId: string, activityId: string, eventId: string): Promise { + const key = `${HOUND_EVENTS}${challengeId}.${activityId}`; + await this.redis.set(key, eventId); + this.redis.expire(key, HOUND_EVENT_CACHE).catch((ex) => { + log.warn(`Failed to set expiry time on ${key}`, ex); + }); + } + + public async getHoundActivity(challengeId: string, activityId: string): Promise { + return this.redis.get(`${HOUND_EVENTS}${challengeId}.${activityId}`); } } diff --git a/src/Stores/StorageProvider.ts b/src/Stores/StorageProvider.ts index 3fbbec48..dbff7f3b 100644 --- a/src/Stores/StorageProvider.ts +++ b/src/Stores/StorageProvider.ts @@ -9,6 +9,8 @@ import { SerializedGitlabDiscussionThreads } from "../Gitlab/Types"; // seen from this feed, up to a max of 10,000. // Adopted from https://github.com/matrix-org/go-neb/blob/babb74fa729882d7265ff507b09080e732d060ae/services/rssbot/rssbot.go#L304 export const MAX_FEED_ITEMS = 10_000; +export const MAX_HOUND_ITEMS = 100; + export interface IBridgeStorageProvider extends IAppserviceStorageProvider, IStorageProvider, ProvisioningStore { connect?(): Promise; @@ -28,6 +30,9 @@ export interface IBridgeStorageProvider extends IAppserviceStorageProvider, ISto storeFeedGuids(url: string, ...guids: string[]): Promise; hasSeenFeed(url: string): Promise; hasSeenFeedGuids(url: string, ...guids: string[]): Promise; - storeHoundActivity(id: string, ...guids: string[]): Promise; - hasSeenHoundActivity(id: string, ...guids: string[]): Promise; + + storeHoundActivity(challengeId: string, ...activityHashes: string[]): Promise; + hasSeenHoundActivity(challengeId: string, ...activityHashes: string[]): Promise; + storeHoundActivityEvent(challengeId: string, activityId: string, eventId: string): Promise; + getHoundActivity(challengeId: string, activityId: string): Promise; } \ No newline at end of file diff --git a/src/hound/reader.ts b/src/hound/reader.ts index 0d6afc50..3c2633f2 100644 --- a/src/hound/reader.ts +++ b/src/hound/reader.ts @@ -5,6 +5,7 @@ import { MessageQueue } from "../MessageQueue"; import { IBridgeStorageProvider } from "../Stores/StorageProvider"; import { BridgeConfigChallengeHound } from "../config/Config"; import { Logger } from "matrix-appservice-bridge"; +import { hashId } from "../libRs"; const log = new Logger("HoundReader"); @@ -74,12 +75,16 @@ export class HoundReader { } } + private static hashActivity(activity: HoundActivity) { + return hashId(activity.activityId + activity.name + activity.distanceKilometers + activity.durationSeconds + activity.elevationMeters); + } + public async poll(challengeId: string) { - const resAct = await this.houndClient.get(`https://api.challengehound.com/challenges/${challengeId}/activities?limit=10`); - const activites = resAct.data as HoundActivity[]; - const seen = await this.storage.hasSeenHoundActivity(challengeId, ...activites.map(a => a.id)); + const resAct = await this.houndClient.get(`https://api.challengehound.com/v1/activities?challengeId=${challengeId}&size=10`); + const activites = (resAct.data["results"] as HoundActivity[]).map(a => ({...a, hash: HoundReader.hashActivity(a)})); + const seen = await this.storage.hasSeenHoundActivity(challengeId, ...activites.map(a => a.hash)); for (const activity of activites) { - if (seen.includes(activity.id)) { + if (seen.includes(activity.hash)) { continue; } this.queue.push({ @@ -91,7 +96,7 @@ export class HoundReader { } }); } - await this.storage.storeHoundActivity(challengeId, ...activites.map(a => a.id)) + await this.storage.storeHoundActivity(challengeId, ...activites.map(a => a.hash)) } public async pollChallenges(): Promise { @@ -112,6 +117,8 @@ export class HoundReader { if (elapsed > this.sleepingInterval) { log.warn(`It took us longer to update the activities than the expected interval`); } + } catch (ex) { + log.warn("Failed to poll for challenge", ex); } finally { this.challengeIds.splice(0, 0, challengeId); }