Skip to content

Commit

Permalink
Merge pull request #349 from matrix-org/hs/last-active
Browse files Browse the repository at this point in the history
Add ActivityTracker component
  • Loading branch information
Half-Shot authored Sep 27, 2021
2 parents d8248eb + 6e80d94 commit 3cb1520
Show file tree
Hide file tree
Showing 8 changed files with 510 additions and 3 deletions.
1 change: 1 addition & 0 deletions changelog.d/594.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add tracking of last active Matrix users (previously maintained in https://github.com/Half-Shot/matrix-lastactive)
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"prepare": "npm run build",
"gendoc": "typedoc",
"lint": "eslint -c .eslintrc.json src/**/*.ts",
"test": "jasmine --stop-on-failure=true",
"test": "ts-node node_modules/jasmine/bin/jasmine --stop-on-failure=true",
"check": "npm run lint && npm test",
"ci-test": "nyc -x \"**/spec/**\" --report text jasmine"
},
Expand All @@ -34,8 +34,8 @@
"extend": "^3.0.2",
"is-my-json-valid": "^2.20.5",
"js-yaml": "^4.0.0",
"matrix-bot-sdk": "^0.6.0-beta.2",
"matrix-appservice": "^0.8.0",
"matrix-bot-sdk": "^0.6.0-beta.2",
"matrix-js-sdk": "^9.9.0",
"nedb": "^1.8.0",
"nopt": "^5.0.0",
Expand All @@ -47,6 +47,7 @@
"devDependencies": {
"@types/express": "^4.17.11",
"@types/extend": "^3.0.1",
"@types/jasmine": "^3.8.2",
"@types/js-yaml": "^4.0.0",
"@types/nedb": "^1.8.11",
"@types/node": "^12",
Expand All @@ -56,6 +57,7 @@
"eslint": "^7.22.0",
"jasmine": "^3.7.0",
"nyc": "^15.1.0",
"ts-node": "^10.2.1",
"typedoc": "^0.20.36",
"typescript": "^4.2.3",
"winston-transport": "^4.4.0"
Expand Down
3 changes: 2 additions & 1 deletion spec/support/jasmine.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
{
"spec_dir": "spec",
"spec_files": [
"**/*[sS]pec.js"
"**/*[sS]pec.js",
"**/*[sS]pec.ts"
],
"helpers": [
"helpers/**/*.js"
Expand Down
205 changes: 205 additions & 0 deletions spec/unit/activity-tracker.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import "jasmine";
import { ActivityTracker } from "../../src/index";
import { WhoisInfo, PresenceEventContent, MatrixClient } from "matrix-bot-sdk";

const TEST_USER = "@foobar:example.com";

function createTracker(canUseWhois: boolean = false, presence?: PresenceEventContent, whois?: WhoisInfo, defaultOnline: boolean = false) {
const client = new MatrixClient("http://example.com", "foo");
client.doRequest = async function (method: string, path: string) {
if (method === "GET" && path === "/_synapse/admin/v1/users/@foo:bar/admin") {
if (canUseWhois) {
throw {statusCode: 400}
}
throw {statusCode: 403}; // 403 - not an admin
}
if (method === "GET" && path.startsWith("/_matrix/client/r0/presence/")) {
if (!presence) {
throw Error("Presence is disabled");
}
return presence;
}
if (method === "GET" && path.startsWith("/_matrix/client/r0/admin/whois")) {
if (!whois) {
throw Error("Whois is disabled");
}
return whois;
}
throw Error("Path/Method is wrong");
}
const tracker = new ActivityTracker(client, {
serverName: "example.com",
usePresence: !!presence,
defaultOnline,
});
return {tracker: tracker as ActivityTracker}
}

describe("ActivityTracker", () => {
it("constructs", () => {
const tracker: any = new ActivityTracker(
new MatrixClient("http://example.com", "foo"),
{
serverName: "example.com",
defaultOnline: false,
});
});
describe("isUserOnline", () => {
it("will enable whois if it can be used", async () => {
const {tracker} = createTracker(true);
tracker.setLastActiveTime(TEST_USER);
await tracker.isUserOnline(TEST_USER, 1000);
expect(tracker.usingWhois).toBeTrue();
});
it("will disable whois if it can't be used", async () => {
const {tracker} = createTracker(false);
tracker.setLastActiveTime(TEST_USER);
await tracker.isUserOnline(TEST_USER, 1000);
expect(tracker.usingWhois).toBeFalse();
});
it("Will return online if user was bumped recently", async () => {
const {tracker} = createTracker(false);
tracker.setLastActiveTime(TEST_USER);
const res = await tracker.isUserOnline(TEST_USER, 100);
expect(res.online).toBeTrue();
expect(res.inactiveMs).toBeLessThan(10);
});
it("will return online if presence is currently active", async () => {
const {tracker} = createTracker(false, {
currently_active: true,
presence: "online",
});
const res = await tracker.isUserOnline(TEST_USER, 1000);
expect(res.online).toBeTrue();
expect(res.inactiveMs).toEqual(0);
});
it("will return online if presence status is online", async () => {
const {tracker} = createTracker(false, {
currently_active: false,
presence: "online"
});
const res = await tracker.isUserOnline(TEST_USER, 1000);
expect(res.online).toBeTrue();
expect(res.inactiveMs).toEqual(0);
});
it("will return offline if presence last_active_ago > maxTime", async () => {
const {tracker} = createTracker(false, {
currently_active: false,
presence: "offline",
last_active_ago: 1001
});
const res = await tracker.isUserOnline(TEST_USER, 1000);
expect(res.online).toBeFalse();
expect(res.inactiveMs).toEqual(1001);
});
it("will return offline if canUseWhois is false and presence couldn't be used", async () => {
const {tracker} = createTracker(false);
const res = await tracker.isUserOnline(TEST_USER, 1000);
expect(res.online).toBeFalse();
expect(res.inactiveMs).toEqual(-1);
});
it("will return online if the user's time is set appropriately", async () => {
const {tracker} = createTracker(false);
const res = await tracker.isUserOnline(TEST_USER, 1000);
expect(res.online).toBeFalse();
expect(res.inactiveMs).toEqual(-1);
const time = Date.now();
await tracker.setLastActiveTime(TEST_USER, time);
const res2 = await tracker.isUserOnline(TEST_USER, 1000);
expect(res2.online).toBeTrue();
expect(res2.inactiveMs).toBeLessThan(100); // Account for some time spent.
});
it("will return online if presence couldn't be used and a device was recently seen", async () => {
const now = Date.now();
const response: WhoisInfo = {
user_id: "@foobar:notexample.com",
devices: {
foobar: {
sessions: [{
connections: [{
ip: "127.0.0.1",
last_seen: now - 500,
user_agent: "FakeDevice/1.0.0",
},{
ip: "127.0.0.1",
last_seen: now - 1500,
user_agent: "FakeDevice/2.0.0",
}],
}],
},
foobar500: {
sessions: [{
connections: [{
ip: "127.0.0.1",
last_seen: now - 2500,
user_agent: "FakeDevice/3.0.0",
}],
}],
},
},
};
const {tracker} = createTracker(true, undefined, response);

const res = await tracker.isUserOnline(TEST_USER, 1000);
expect(res.online).toBeTrue();
});
it("will return offline if presence couldn't be used and a device was not recently seen", async () => {
const now = Date.now();
const response: WhoisInfo = {
user_id: "@foobar:notexample.com",
devices: {
foobar: {
sessions: [{
connections: [{
ip: "127.0.0.1",
last_seen: now - 1000,
user_agent: "FakeDevice/1.0.0",
},{
ip: "127.0.0.1",
last_seen: now - 1500,
user_agent: "FakeDevice/2.0.0",
}],
}],
},
foobar500: {
sessions: [{
connections: [{
ip: "127.0.0.1",
last_seen: now - 2500,
user_agent: "FakeDevice/3.0.0",
}],
}],
},
},
};
const {tracker} = createTracker(true, undefined, response);

const res = await tracker.isUserOnline(TEST_USER, 1000);
expect(res.online).toBeFalse();
});
it("will default to offline if configured to", async () => {
const {tracker} = createTracker(false, undefined, undefined, false);
const res = await tracker.isUserOnline(TEST_USER, 1000);
expect(res.online).toBeFalse();
expect(res.inactiveMs).toEqual(-1);
});
it("will default to online if configured to", async () => {
const {tracker} = createTracker(false, undefined, undefined, true);
const res = await tracker.isUserOnline(TEST_USER, 1000);
expect(res.online).toBeTrue();
expect(res.inactiveMs).toEqual(-1);
});
it("will be online if defaultOnline is overriden", async () => {
const {tracker} = createTracker(false, undefined, undefined, false);
const res = await tracker.isUserOnline(TEST_USER, 1000, true);
expect(res.online).toBeTrue();
expect(res.inactiveMs).toEqual(-1);
});
it("will be offline if defaultOnline is overriden", async () => {
const {tracker} = createTracker(false, undefined, undefined, true);
const res = await tracker.isUserOnline(TEST_USER, 1000, false);
expect(res.online).toBeFalse();
expect(res.inactiveMs).toEqual(-1);
});
})
});
71 changes: 71 additions & 0 deletions src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { Registry } from "prom-client";
import { ClientEncryptionStore, EncryptedEventBroker } from "./components/encryption";
import { EphemeralEvent, PresenceEvent, ReadReceiptEvent, TypingEvent, WeakEvent } from "./components/event-types";
import * as BotSDK from "matrix-bot-sdk";
import { ActivityTracker, ActivityTrackerOpts } from "./components/activity-tracker";

const log = logging.get("bridge");

Expand All @@ -65,6 +66,9 @@ const INTENT_CULL_EVICT_AFTER_MS = 1000 * 60 * 15; // 15 minutes
export const BRIDGE_PING_EVENT_TYPE = "org.matrix.bridge.ping";
export const BRIDGE_PING_TIMEOUT_MS = 60000;

// How old can a receipt be before we treat it as stale.
const RECEIPT_CUTOFF_TIME_MS = 60000;

export interface BridgeController {
/**
* The bridge will invoke when an event has been received from the HS.
Expand Down Expand Up @@ -276,6 +280,8 @@ export interface BridgeOpts {
allowEventOnLookupFail: boolean;
};
};

trackUserActivity?: ActivityTrackerOpts;
}

interface VettedBridgeOpts {
Expand Down Expand Up @@ -421,6 +427,7 @@ interface VettedBridgeOpts {
allowEventOnLookupFail: boolean;
};
};
userActivityTracking?: ActivityTrackerOpts;
}

export class Bridge {
Expand Down Expand Up @@ -456,6 +463,12 @@ export class Bridge {

public readonly opts: VettedBridgeOpts;

private internalActivityTracker?: ActivityTracker;

public get activityTracker(): ActivityTracker|undefined {
return this.internalActivityTracker;
}

public get appService(): AppService {
if (!this.appservice) {
throw Error('appservice not defined yet');
Expand Down Expand Up @@ -714,6 +727,16 @@ export class Bridge {

this.setupIntentCulling();

if (this.opts.userActivityTracking) {
if (!this.registration.pushEphemeral) {
log.info("Sending ephemeral events to the bridge is currently disabled in the registration file," +
" so user activity will not be captured");
}
this.internalActivityTracker = new ActivityTracker(
this.botIntent.matrixClient, this.opts.userActivityTracking
);
}

await this.loadDatabases();
}

Expand Down Expand Up @@ -1270,6 +1293,12 @@ export class Bridge {
}

private async onEphemeralEvent(event: EphemeralEvent) {
try {
await this.onEphemeralActivity(event);
}
catch (ex) {
log.error(`Failed to handle ephemeral activity`, ex);
}
if (this.opts.controller.onEphemeralEvent) {
const request = this.requestFactory.newRequest({ data: event });
await this.opts.controller.onEphemeralEvent(request as Request<EphemeralEvent>);
Expand Down Expand Up @@ -1349,6 +1378,10 @@ export class Bridge {
return null;
}

if (this.activityTracker && event.sender && event.origin_server_ts) {
this.activityTracker.setLastActiveTime(event.sender, );
}

// eslint-disable-next-line camelcase
const relatesTo = event.content?.['m.relates_to'] as { event_id?: string; rel_type: "m.replace";}|undefined;
const editOptions = this.opts.eventValidation?.validateEditSender;
Expand Down Expand Up @@ -1690,6 +1723,44 @@ export class Bridge {
return new Intent(botIntent, this.botSdkAS.botClient, intentOpts);
}

private onEphemeralActivity(event: EphemeralEvent) {
if (!this.activityTracker) {
// Not in use.
return;
}
// If we see one of these events over federation, bump the
// last active time for those users.
let userIds: string[]|undefined = undefined;
if (!this.activityTracker) {
return;
}
if (event.type === "m.presence" && event.content.presence === "online") {
userIds = [event.sender];
}
else if (event.type === "m.receipt") {
userIds = [];
const currentTime = Date.now();
// The homeserver will send us a map of all userIDs => ts for each event.
// We are only interested in recent receipts though.
for (const eventData of Object.values(event.content).map((v) => v["m.read"])) {
for (const [userId, { ts }] of Object.entries(eventData)) {
if (currentTime - ts <= RECEIPT_CUTOFF_TIME_MS) {
userIds.push(userId);
}
}
}
}
else if (event.type === "m.typing") {
userIds = event.content.user_ids;
}

if (userIds) {
for (const userId of userIds) {
this.activityTracker.setLastActiveTime(userId);
}
}
}

}

function loadDatabase<T extends BridgeStore>(path: string, Cls: new (db: Datastore) => T) {
Expand Down
Loading

0 comments on commit 3cb1520

Please sign in to comment.