Skip to content

Commit 4839340

Browse files
authored
Add support for edit tracking in challenge hound & fix a few bugs. (#927)
* Fix a namespace conflict. * Add support for edits. * Couple of bugfixes. * changelog * Support pkcs1 format keys. * Add docs for official API. * couple of cleanups * Revert "Support pkcs1 format keys." This reverts commit 157cc4a.
1 parent 1b5e0a4 commit 4839340

File tree

7 files changed

+138
-68
lines changed

7 files changed

+138
-68
lines changed

changelog.d/927.bugfix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix a few bugs introduced in challenge hound support.

docs/setup/challengehound.md

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,14 @@ into Matrix.
55

66
### Getting the API secret.
77

8-
Unfortunately, there is no way to directly request a persistent Challenge Hound API token. The
9-
only way to authenticate with the service at present is to login with an email address and receive
10-
a magic token in an email. This is not something Hookshot has the capability to do on it's own.
11-
12-
In order to extract the token for use with the bridge, login to Challenge Hound. Once logged in,
13-
please locate the local storage via the devtools of your browser. Inside you will find a `ch:user`
14-
entry with a `token` value. That value should be used as the secret for your Hookshot config.
8+
You will need to email ChallengeHound support for an API token. They seem happy to provide one
9+
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)
1510

1611
```yaml
1712
challengeHound:
1813
token: <the token>
1914
```
2015
21-
This token tends to expire roughly once a month, and for the moment you'll need to manually
22-
replace it. You can also ask Challenge Hound's support for an API key, although this has not
23-
been tested.
24-
2516
## Usage
2617
2718
You can add a new challenge hound challenge by command:

src/Connections/HoundConnection.ts

Lines changed: 76 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { BaseConnection } from "./BaseConnection";
44
import { IConnection, IConnectionState } from ".";
55
import { Connection, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection";
66
import { CommandError } from "../errors";
7-
7+
import { IBridgeStorageProvider } from "../Stores/StorageProvider";
8+
import { Logger } from "matrix-appservice-bridge";
89
export interface HoundConnectionState extends IConnectionState {
910
challengeId: string;
1011
}
@@ -14,20 +15,44 @@ export interface HoundPayload {
1415
challengeId: string,
1516
}
1617

18+
/**
19+
* @url https://documenter.getpostman.com/view/22349866/UzXLzJUV#0913e0b9-9cb5-440e-9d8d-bf6430285ee9
20+
*/
1721
export interface HoundActivity {
18-
id: string;
19-
distance: number; // in meters
20-
duration: number;
21-
elevation: number;
22-
createdAt: string;
23-
activityType: string;
24-
activityName: string;
25-
user: {
26-
id: string;
27-
fullname: string;
28-
fname: string;
29-
lname: string;
30-
}
22+
userId: string,
23+
activityId: string,
24+
participant: string,
25+
/**
26+
* @example "07/26/2022"
27+
*/
28+
date: string,
29+
/**
30+
* @example "2022-07-26T13:49:22Z"
31+
*/
32+
datetime: string,
33+
name: string,
34+
type: string,
35+
/**
36+
* @example strava
37+
*/
38+
app: string,
39+
durationSeconds: number,
40+
/**
41+
* @example "1.39"
42+
*/
43+
distanceKilometers: string,
44+
/**
45+
* @example "0.86"
46+
*/
47+
distanceMiles: string,
48+
/**
49+
* @example "0.86"
50+
*/
51+
elevationMeters: string,
52+
/**
53+
* @example "0.86"
54+
*/
55+
elevationFeet: string,
3156
}
3257

3358
export interface IChallenge {
@@ -76,6 +101,7 @@ function getEmojiForType(type: string) {
76101
}
77102
}
78103

104+
const log = new Logger("HoundConnection");
79105
const md = markdownit();
80106
@Connection
81107
export class HoundConnection extends BaseConnection implements IConnection {
@@ -95,12 +121,12 @@ export class HoundConnection extends BaseConnection implements IConnection {
95121

96122
public static validateState(data: Record<string, unknown>): HoundConnectionState {
97123
// Convert URL to ID.
98-
if (!data.challengeId && data.url && data.url === "string") {
124+
if (!data.challengeId && data.url && typeof data.url === "string") {
99125
data.challengeId = this.getIdFromURL(data.url);
100126
}
101127

102128
// Test for v1 uuid.
103-
if (!data.challengeId || typeof data.challengeId !== "string" || /^\w{8}(?:-\w{4}){3}-\w{12}$/.test(data.challengeId)) {
129+
if (!data.challengeId || typeof data.challengeId !== "string" || !/^\w{8}(?:-\w{4}){3}-\w{12}$/.test(data.challengeId)) {
104130
throw Error('Missing or invalid id');
105131
}
106132

@@ -109,14 +135,14 @@ export class HoundConnection extends BaseConnection implements IConnection {
109135
}
110136
}
111137

112-
public static createConnectionForState(roomId: string, event: StateEvent<Record<string, unknown>>, {config, intent}: InstantiateConnectionOpts) {
138+
public static createConnectionForState(roomId: string, event: StateEvent<Record<string, unknown>>, {config, intent, storage}: InstantiateConnectionOpts) {
113139
if (!config.challengeHound) {
114140
throw Error('Challenge hound is not configured');
115141
}
116-
return new HoundConnection(roomId, event.stateKey, this.validateState(event.content), intent);
142+
return new HoundConnection(roomId, event.stateKey, this.validateState(event.content), intent, storage);
117143
}
118144

119-
static async provisionConnection(roomId: string, _userId: string, data: Record<string, unknown> = {}, {intent, config}: ProvisionConnectionOpts) {
145+
static async provisionConnection(roomId: string, _userId: string, data: Record<string, unknown> = {}, {intent, config, storage}: ProvisionConnectionOpts) {
120146
if (!config.challengeHound) {
121147
throw Error('Challenge hound is not configured');
122148
}
@@ -127,7 +153,7 @@ export class HoundConnection extends BaseConnection implements IConnection {
127153
throw new CommandError(`Fetch failed, status ${statusDataRequest.status}`, "Challenge could not be found. Is it active?");
128154
}
129155
const { challengeName } = await statusDataRequest.json() as {challengeName: string};
130-
const connection = new HoundConnection(roomId, validState.challengeId, validState, intent);
156+
const connection = new HoundConnection(roomId, validState.challengeId, validState, intent, storage);
131157
await intent.underlyingClient.sendStateEvent(roomId, HoundConnection.CanonicalEventType, validState.challengeId, validState);
132158
return {
133159
connection,
@@ -140,7 +166,8 @@ export class HoundConnection extends BaseConnection implements IConnection {
140166
roomId: string,
141167
stateKey: string,
142168
private state: HoundConnectionState,
143-
private readonly intent: Intent) {
169+
private readonly intent: Intent,
170+
private readonly storage: IBridgeStorageProvider) {
144171
super(roomId, stateKey, HoundConnection.CanonicalEventType)
145172
}
146173

@@ -156,25 +183,41 @@ export class HoundConnection extends BaseConnection implements IConnection {
156183
return this.state.priority || super.priority;
157184
}
158185

159-
public async handleNewActivity(payload: HoundActivity) {
160-
const distance = `${(payload.distance / 1000).toFixed(2)}km`;
161-
const emoji = getEmojiForType(payload.activityType);
162-
const body = `🎉 **${payload.user.fullname}** completed a ${distance} ${emoji} ${payload.activityType} (${payload.activityName})`;
163-
const content: any = {
186+
public async handleNewActivity(activity: HoundActivity) {
187+
log.info(`New activity recorded ${activity.activityId}`);
188+
const existingActivityEventId = await this.storage.getHoundActivity(this.challengeId, activity.activityId);
189+
const distance = parseFloat(activity.distanceKilometers);
190+
const distanceUnits = `${(distance).toFixed(2)}km`;
191+
const emoji = getEmojiForType(activity.type);
192+
const body = `🎉 **${activity.participant}** completed a ${distanceUnits} ${emoji} ${activity.type} (${activity.name})`;
193+
let content: any = {
164194
body,
165195
format: "org.matrix.custom.html",
166196
formatted_body: md.renderInline(body),
167197
};
168198
content["msgtype"] = "m.notice";
169-
content["uk.half-shot.matrix-challenger.activity.id"] = payload.id;
170-
content["uk.half-shot.matrix-challenger.activity.distance"] = Math.round(payload.distance);
171-
content["uk.half-shot.matrix-challenger.activity.elevation"] = Math.round(payload.elevation);
172-
content["uk.half-shot.matrix-challenger.activity.duration"] = Math.round(payload.duration);
199+
content["uk.half-shot.matrix-challenger.activity.id"] = activity.activityId;
200+
content["uk.half-shot.matrix-challenger.activity.distance"] = Math.round(distance * 1000);
201+
content["uk.half-shot.matrix-challenger.activity.elevation"] = Math.round(parseFloat(activity.elevationMeters));
202+
content["uk.half-shot.matrix-challenger.activity.duration"] = Math.round(activity.durationSeconds);
173203
content["uk.half-shot.matrix-challenger.activity.user"] = {
174-
"name": payload.user.fullname,
175-
id: payload.user.id,
204+
"name": activity.participant,
205+
id: activity.userId,
176206
};
177-
await this.intent.underlyingClient.sendMessage(this.roomId, content);
207+
if (existingActivityEventId) {
208+
log.debug(`Updating existing activity ${activity.activityId} ${existingActivityEventId}`);
209+
content = {
210+
body: `* ${content.body}`,
211+
msgtype: "m.notice",
212+
"m.new_content": content,
213+
"m.relates_to": {
214+
"event_id": existingActivityEventId,
215+
"rel_type": "m.replace"
216+
},
217+
};
218+
}
219+
const eventId = await this.intent.underlyingClient.sendMessage(this.roomId, content);
220+
await this.storage.storeHoundActivityEvent(this.challengeId, activity.activityId, eventId);
178221
}
179222

180223
public toString() {

src/Stores/MemoryStorageProvider.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export class MemoryStorageProvider extends MSP implements IBridgeStorageProvider
1515
private gitlabDiscussionThreads = new Map<string, SerializedGitlabDiscussionThreads>();
1616
private feedGuids = new Map<string, Array<string>>();
1717
private houndActivityIds = new Map<string, Array<string>>();
18+
private houndActivityIdToEvent = new Map<string, string>();
1819

1920
constructor() {
2021
super();
@@ -110,19 +111,27 @@ export class MemoryStorageProvider extends MSP implements IBridgeStorageProvider
110111
this.gitlabDiscussionThreads.set(connectionId, value);
111112
}
112113

113-
async storeHoundActivity(url: string, ...ids: string[]): Promise<void> {
114-
let set = this.houndActivityIds.get(url);
114+
async storeHoundActivity(challengeId: string, ...activityIds: string[]): Promise<void> {
115+
let set = this.houndActivityIds.get(challengeId);
115116
if (!set) {
116117
set = []
117-
this.houndActivityIds.set(url, set);
118+
this.houndActivityIds.set(challengeId, set);
118119
}
119-
set.unshift(...ids);
120+
set.unshift(...activityIds);
120121
while (set.length > MAX_FEED_ITEMS) {
121122
set.pop();
122123
}
123124
}
124-
async hasSeenHoundActivity(url: string, ...ids: string[]): Promise<string[]> {
125-
const existing = this.houndActivityIds.get(url);
126-
return existing ? ids.filter((existingGuid) => existing.includes(existingGuid)) : [];
125+
async hasSeenHoundActivity(challengeId: string, ...activityIds: string[]): Promise<string[]> {
126+
const existing = this.houndActivityIds.get(challengeId);
127+
return existing ? activityIds.filter((existingGuid) => existing.includes(existingGuid)) : [];
128+
}
129+
130+
public async storeHoundActivityEvent(challengeId: string, activityId: string, eventId: string): Promise<void> {
131+
this.houndActivityIdToEvent.set(`${challengeId}.${activityId}`, eventId);
132+
}
133+
134+
public async getHoundActivity(challengeId: string, activityId: string): Promise<string|null> {
135+
return this.houndActivityIdToEvent.get(`${challengeId}.${activityId}`) ?? null;
127136
}
128137
}

src/Stores/RedisStorageProvider.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,15 @@ const STORED_FILES_EXPIRE_AFTER = 24 * 60 * 60; // 24 hours
2323
const COMPLETED_TRANSACTIONS_EXPIRE_AFTER = 24 * 60 * 60; // 24 hours
2424
const ISSUES_EXPIRE_AFTER = 7 * 24 * 60 * 60; // 7 days
2525
const ISSUES_LAST_COMMENT_EXPIRE_AFTER = 14 * 24 * 60 * 60; // 7 days
26+
const HOUND_EVENT_CACHE = 90 * 24 * 60 * 60; // 30 days
2627

2728

2829
const WIDGET_TOKENS = "widgets.tokens.";
2930
const WIDGET_USER_TOKENS = "widgets.user-tokens.";
3031

3132
const FEED_GUIDS = "feeds.guids.";
32-
const HOUND_IDS = "feeds.guids.";
33+
const HOUND_GUIDS = "hound.guids.";
34+
const HOUND_EVENTS = "hound.events.";
3335

3436
const log = new Logger("RedisASProvider");
3537

@@ -242,24 +244,36 @@ export class RedisStorageProvider extends RedisStorageContextualProvider impleme
242244
return guids.filter((_guid, index) => res[index][1] !== null);
243245
}
244246

245-
public async storeHoundActivity(url: string, ...guids: string[]): Promise<void> {
246-
const feedKey = `${HOUND_IDS}${url}`;
247-
await this.redis.lpush(feedKey, ...guids);
248-
await this.redis.ltrim(feedKey, 0, MAX_FEED_ITEMS);
247+
public async storeHoundActivity(challengeId: string, ...activityHashes: string[]): Promise<void> {
248+
const key = `${HOUND_GUIDS}${challengeId}`;
249+
await this.redis.lpush(key, ...activityHashes);
250+
await this.redis.ltrim(key, 0, MAX_FEED_ITEMS);
249251
}
250252

251-
public async hasSeenHoundActivity(url: string, ...guids: string[]): Promise<string[]> {
253+
public async hasSeenHoundActivity(challengeId: string, ...activityHashes: string[]): Promise<string[]> {
252254
let multi = this.redis.multi();
253-
const feedKey = `${HOUND_IDS}${url}`;
255+
const key = `${HOUND_GUIDS}${challengeId}`;
254256

255-
for (const guid of guids) {
256-
multi = multi.lpos(feedKey, guid);
257+
for (const guid of activityHashes) {
258+
multi = multi.lpos(key, guid);
257259
}
258260
const res = await multi.exec();
259261
if (res === null) {
260262
// Just assume we've seen none.
261263
return [];
262264
}
263-
return guids.filter((_guid, index) => res[index][1] !== null);
265+
return activityHashes.filter((_guid, index) => res[index][1] !== null);
266+
}
267+
268+
public async storeHoundActivityEvent(challengeId: string, activityId: string, eventId: string): Promise<void> {
269+
const key = `${HOUND_EVENTS}${challengeId}.${activityId}`;
270+
await this.redis.set(key, eventId);
271+
this.redis.expire(key, HOUND_EVENT_CACHE).catch((ex) => {
272+
log.warn(`Failed to set expiry time on ${key}`, ex);
273+
});
274+
}
275+
276+
public async getHoundActivity(challengeId: string, activityId: string): Promise<string|null> {
277+
return this.redis.get(`${HOUND_EVENTS}${challengeId}.${activityId}`);
264278
}
265279
}

src/Stores/StorageProvider.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { SerializedGitlabDiscussionThreads } from "../Gitlab/Types";
99
// seen from this feed, up to a max of 10,000.
1010
// Adopted from https://github.com/matrix-org/go-neb/blob/babb74fa729882d7265ff507b09080e732d060ae/services/rssbot/rssbot.go#L304
1111
export const MAX_FEED_ITEMS = 10_000;
12+
export const MAX_HOUND_ITEMS = 100;
13+
1214

1315
export interface IBridgeStorageProvider extends IAppserviceStorageProvider, IStorageProvider, ProvisioningStore {
1416
connect?(): Promise<void>;
@@ -28,6 +30,9 @@ export interface IBridgeStorageProvider extends IAppserviceStorageProvider, ISto
2830
storeFeedGuids(url: string, ...guids: string[]): Promise<void>;
2931
hasSeenFeed(url: string): Promise<boolean>;
3032
hasSeenFeedGuids(url: string, ...guids: string[]): Promise<string[]>;
31-
storeHoundActivity(id: string, ...guids: string[]): Promise<void>;
32-
hasSeenHoundActivity(id: string, ...guids: string[]): Promise<string[]>;
33+
34+
storeHoundActivity(challengeId: string, ...activityHashes: string[]): Promise<void>;
35+
hasSeenHoundActivity(challengeId: string, ...activityHashes: string[]): Promise<string[]>;
36+
storeHoundActivityEvent(challengeId: string, activityId: string, eventId: string): Promise<void>;
37+
getHoundActivity(challengeId: string, activityId: string): Promise<string|null>;
3338
}

src/hound/reader.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { MessageQueue } from "../MessageQueue";
55
import { IBridgeStorageProvider } from "../Stores/StorageProvider";
66
import { BridgeConfigChallengeHound } from "../config/Config";
77
import { Logger } from "matrix-appservice-bridge";
8+
import { hashId } from "../libRs";
89

910
const log = new Logger("HoundReader");
1011

@@ -74,12 +75,16 @@ export class HoundReader {
7475
}
7576
}
7677

78+
private static hashActivity(activity: HoundActivity) {
79+
return hashId(activity.activityId + activity.name + activity.distanceKilometers + activity.durationSeconds + activity.elevationMeters);
80+
}
81+
7782
public async poll(challengeId: string) {
78-
const resAct = await this.houndClient.get(`https://api.challengehound.com/challenges/${challengeId}/activities?limit=10`);
79-
const activites = resAct.data as HoundActivity[];
80-
const seen = await this.storage.hasSeenHoundActivity(challengeId, ...activites.map(a => a.id));
83+
const resAct = await this.houndClient.get(`https://api.challengehound.com/v1/activities?challengeId=${challengeId}&size=10`);
84+
const activites = (resAct.data["results"] as HoundActivity[]).map(a => ({...a, hash: HoundReader.hashActivity(a)}));
85+
const seen = await this.storage.hasSeenHoundActivity(challengeId, ...activites.map(a => a.hash));
8186
for (const activity of activites) {
82-
if (seen.includes(activity.id)) {
87+
if (seen.includes(activity.hash)) {
8388
continue;
8489
}
8590
this.queue.push<HoundPayload>({
@@ -91,7 +96,7 @@ export class HoundReader {
9196
}
9297
});
9398
}
94-
await this.storage.storeHoundActivity(challengeId, ...activites.map(a => a.id))
99+
await this.storage.storeHoundActivity(challengeId, ...activites.map(a => a.hash))
95100
}
96101

97102
public async pollChallenges(): Promise<void> {
@@ -112,6 +117,8 @@ export class HoundReader {
112117
if (elapsed > this.sleepingInterval) {
113118
log.warn(`It took us longer to update the activities than the expected interval`);
114119
}
120+
} catch (ex) {
121+
log.warn("Failed to poll for challenge", ex);
115122
} finally {
116123
this.challengeIds.splice(0, 0, challengeId);
117124
}

0 commit comments

Comments
 (0)