Add overpass reports, add nomention and nobot support

This commit is contained in:
Pieter Vander Vennet 2023-01-18 01:08:02 +01:00
parent d2ddc3227d
commit c699f13c78
8 changed files with 315 additions and 89 deletions

26
README.md Normal file
View file

@ -0,0 +1,26 @@
# Mastodon-Bot
This is a bot which is built to post a daily message about mapcomplete-usage.
## How it works
It fetches latest changesets from OsmCha, processes them and posts them to Mastodon.
Changesets which have the `add-image`-tag are downloaded from the OSM-api and crawled for images, of which some are picked to add to the posts.
Note that the image selection process is opinionated: some themes (artwork, nature, trees, ...) have a higher probability of being picked.
Furthermore, it tries to pick at most one image per contributor - so images by the same contributor will only be used if there are more images to add then contributors.
## Your bot mentioned me - I don't want that!
You can indicate this to the bot in the following way:
### On Mastodon
- You can mute or block the bot so that you won't see the posts. Your user account on OpenStreetMap or your Mastodon-username will still be mentioned in the posts
- You can add the hashtag `#nobot` or `#no-mapcomplete-bot` to your profile description. The bot will not mention you with your Mastodon-handle, but it will still post your OSM-username, your pictures and a report of your contributions
### On your OSM-user profile:
- Add `#no-bot` or `#no-mapcomplete-bot` to your user profile and your contributions (map changes and pictures) will not be included in the bot at all
- Add `#nobotmention` or `#nomapcompletebotmention` to your user profile. Your contributions and pictures will still be listed in the bot, with your OSM-username. However, your OSM-username will _not_ be replaced by your Mastodon-handle, thus _not_ pinging you.

View file

@ -1,8 +1,31 @@
import {LoginSettings} from "./Mastodon";
export interface MapCompleteUsageOverview {
topContributorsNumberToShow: number,
topThemesNumberToShow: number,
showTopContributors?: boolean,
showTopThemes?: boolean,
/**
* Term to use in 'created/moved/deleted one point'
*/
poiName?: "point" | string
/**
* Term to use in 'created/moved/deleted n points'
*/
poisName?: "points" | string
report?:{
overpassQuery: string,
/**
* Values {total} and {date} will be substituted
*/
post: string
/**
* Default: global bbox
* Use something like `[bbox:51.19999983412068,2.8564453125,51.41976382669736,3.416748046875]` if only a certain region should be sent
*/
bbox?: string
}
/**
* The number of days to look back.
@ -35,5 +58,5 @@ export default interface Config {
/** IF set: prints to console instead of to Mastodon*/
dryrun?: boolean
},
actions: {mapCompleteUsageOverview : MapCompleteUsageOverview} []
actions: MapCompleteUsageOverview []
}

View file

@ -1,6 +1,5 @@
import {login, LoginParams} from 'masto';
import * as fs from "fs";
import {stat} from "fs";
export interface LoginSettings {
url: string,
@ -22,41 +21,96 @@ export interface CreateStatusParamsBase {
readonly mediaIds?: readonly string[];
}
export default class MastodonPoster {
/**
* The actual instance, see https://www.npmjs.com/package/mastodon
* @private
*/
private readonly instance ;
private readonly instance;
private _dryrun: boolean;
private _userInfoCache: Record<string, any> = {}
private constructor(masto, dryrun: boolean) {
this.instance = masto
this._dryrun = dryrun;
}
public async doStuff(){
public static async construct(settings: LoginParams & { dryrun?: boolean }) {
return new MastodonPoster(await login(settings), settings.dryrun ?? false)
}
public async writeMessage(text: string, options?: CreateStatusParamsBase): Promise<{id: string}>{
if(this._dryrun){
console.log("Dryrun enabled - not posting",options?.visibility??"public","message: \n" + text.split("\n").map(txt => " > "+txt).join("\n"))
public async writeMessage(text: string, options?: CreateStatusParamsBase): Promise<{ id: string }> {
if (options?.visibility === "direct" && text.indexOf("@") < 0) {
throw ("Error: you try to send a direct message, but it has no username...")
}
if (text.length > 500) {
console.log(text.split("\n").map(txt => " > " + txt).join("\n"))
throw "Error: text is too long:" + text.length
}
if (text.length == 0) {
console.log("Not posting an empty message")
return
}
if (this._dryrun) {
console.log("Dryrun enabled - not posting", options?.visibility ?? "public", "message: \n" + text.split("\n").map(txt => " > " + txt).join("\n"))
return {id: "some_id"}
}
const statusUpate = await this.instance.v1.statuses.create({
visibility: 'public',
...(options??{}),
...(options ?? {}),
status: text
})
console.dir(statusUpate)
console.log("Posted to", statusUpate.url)
console.log(text.split("\n").map(txt => " > "+txt).join("\n"))
console.log(text.split("\n").map(txt => " > " + txt).join("\n"))
return statusUpate
}
public static async construct(settings: LoginParams & {dryrun?: boolean}) {
return new MastodonPoster(await login(settings), settings.dryrun ?? false)
public async hasNoBot(username: string): Promise<boolean> {
const info = await this.userInfoFor(username)
const descrParts = info.note?.replace(/-/g, "")?.toLowerCase()?.split(" ") ?? []
if (descrParts.indexOf("#nobot") >= 0 || descrParts.indexOf("#nomapcompletebot") >= 0) {
return true
}
const nobot = info.fields.find(f => f.name === "nobot").value
if (nobot.toLowerCase() === "yes" || nobot.toLowerCase() === "true") {
return true
}
return false
}
public async userInfoFor(username: string): Promise<{
id: string,
/*Fully qualified user name*/
acct: string
displayname: string,
bot: boolean,
/* User-set biography */
note: string,
url: string,
avatar: string,
avatarStatic: string,
header: string,
headerStatic: string,
followersCount: number,
followingCount: number,
statusesCount: number,
fields: { name: string, value: string }[]
}> {
if (this._userInfoCache[username]) {
return this._userInfoCache[username]
}
const acct = await this.instance.v1.accounts.lookup({
acct: username,
});
const info = await this.instance.v1.accounts.fetch(acct.id)
this._userInfoCache[username] = info
return info
}
/**
* Uploads the image; returns the id of the image
@ -64,15 +118,25 @@ export default class MastodonPoster {
* @param description
*/
public async uploadImage(path: string, description: string): Promise<string> {
if(this._dryrun){
if (this._dryrun) {
console.log("As dryrun is enabled: not uploading ", path)
return "some_id"
}
console.log("Uploading", path)
try {
const mediaAttachment = await this.instance.v2.mediaAttachments.create({
file: new Blob([fs.readFileSync(path)]),
description
})
return mediaAttachment.id
} catch (e) {
console.log("Could not upload image " + path + " due to ", e, "Trying again...")
const mediaAttachment = await this.instance.v2.mediaAttachments.create({
file: new Blob([fs.readFileSync(path)]),
description
})
return mediaAttachment.id
}
}
}

View file

@ -1,5 +1,6 @@
import Utils from "./Utils";
import * as fs from "fs";
import MastodonPoster from "./Mastodon";
export interface UserInfo {
"id": number,
@ -40,7 +41,27 @@ export default class OsmUserInfo {
}
public async GetMastodonLink(): Promise<string | undefined> {
public async hasNoBotTag(): Promise<{
nobot: boolean,
nomention: boolean
}>{
const description = (await this.getUserInfo()).description ?? ""
const split = description.toLowerCase().replace(/-/g, "").split(" ")
const nobot = split.indexOf("#nobot") >=0 || split.indexOf("#nomapcompletebot") >= 0
const nomention = split.indexOf("#nobotmention") >=0 || split.indexOf("#nomapcompletebotmention") >= 0
return {nobot, nomention}
}
/**
* Gets the Mastodon username of the this OSM-user to ping them.
* @param mastodonApi: will be used to lookup the metadata of the user; if they have '#nobot' in their bio, don't mention them
* @constructor
*/
public async GetMastodonUsername(mastodonApi: MastodonPoster): Promise<string | undefined> {
const {nomention} = await this.hasNoBotTag()
if(nomention){
return undefined
}
const mastodonLinks = await this.getMeLinks()
if (mastodonLinks.length <= 0) {
@ -48,7 +69,13 @@ export default class OsmUserInfo {
}
const url = new URL(mastodonLinks[0])
return url.pathname.substring(1) + "@" + url.host
const username = url.pathname.substring(1) + "@" + url.host
if(await mastodonApi.hasNoBot(username)){
return undefined
}
const useraccount = await mastodonApi.userInfoFor(username)
return useraccount.acct
}
public async getMeLinks(): Promise<string[]> {
@ -80,7 +107,7 @@ export default class OsmUserInfo {
}
}
const url = `${this._backend}api/0.6/user/${this._userId}.json`
console.log("Looking up user info about ", this._userId)
console.log("Looking up OSM user info about ", this._userId)
const res = await Utils.DownloadJson(url);
this._userData = res.user
if (this._cachingPath !== undefined) {

28
src/Overpass.ts Normal file
View file

@ -0,0 +1,28 @@
import Utils from "./Utils";
export default class Overpass {
private readonly bbox: string;
private readonly overpassQuery: string
constructor(options: {
bbox?: string,
overpassQuery: string
}) {
this.bbox = options.bbox ?? "";
this.overpassQuery = options.overpassQuery
}
private constructUrl() {
// https://overpass-api.de/api/interpreter?data=[out:json][timeout:180];(nwr[%22memorial%22=%22ghost_bike%22];);out%20body;out%20meta;%3E;out%20skel%20qt;
const param = `[out:json][timeout:180]${this.bbox};(${this.overpassQuery});out body;out meta;>;out skel qt;`
return `https://overpass-api.de/api/interpreter?data=${(param)}`
}
public query(){
console.log("Querying overpass: ", this.constructUrl())
return Utils.DownloadJson(this.constructUrl())
}
}

View file

@ -5,12 +5,14 @@ import OsmUserInfo from "./OsmUserInfo";
import Config, {MapCompleteUsageOverview} from "./Config";
import MastodonPoster from "./Mastodon";
import ImgurAttribution from "./ImgurAttribution";
import Overpass from "./Overpass";
type ImageInfo = { image: string, changeset: ChangeSetData }
export class Postbuilder {
private static readonly metakeys = [
"answer",
"create",
"add-image",
"move",
"delete",
@ -33,7 +35,13 @@ export class Postbuilder {
}
getStatisticsFor(changesetsMade?: ChangeSetData[]): { total: number, addImage?: number, deleted: number, answered?: number, moved?: number, summaryText?: string } {
getStatisticsFor(changesetsMade?: ChangeSetData[]): { total: number,
answered?: number,
created?: number,
addImage?: number,
deleted: number,
moved?: number,
summaryText?: string } {
const stats: Record<string, number> = {}
changesetsMade ??= this._changesetsMade
@ -48,11 +56,20 @@ export class Postbuilder {
}
let overview: string[] = []
const {answer, move} = stats
const {answer, move, create} = stats
const deleted = stats.delete
const images = stats["add-image"]
const plantnetDetected = stats["plantnet-ai-detection"]
const linkedImages = stats["link-image"]
const poi = this._config.poiName ?? "point"
const pois = this._config.poisName ?? "points"
if(create){
if (create == 1) {
overview.push("added one "+poi)
} else {
overview.push("added " + create +" "+pois)
}
}
if (answer) {
if (answer == 1) {
overview.push("answered one question")
@ -70,17 +87,17 @@ export class Postbuilder {
if (move) {
if (move == 1) {
overview.push("moved one point")
overview.push("moved one "+poi)
} else {
overview.push("moved " + move + " points")
overview.push("moved " + move + " "+pois)
}
}
if (deleted) {
if (deleted == 1) {
overview.push("delted one deleted")
overview.push("deleted one "+poi)
} else {
overview.push("deleted " + deleted + " points")
overview.push("deleted " + deleted + " "+pois)
}
}
@ -120,16 +137,18 @@ export class Postbuilder {
const themes = new Histogram(changesetsMade, cs => cs.properties.theme)
let username = await userinfo.GetMastodonLink() ?? inf.display_name
let username = await userinfo.GetMastodonUsername(this._poster) ?? inf.display_name
const statistics = this.getStatisticsFor(changesetsMade)
let thematicMaps = "maps " + Utils.commasAnd(themes.keys())
if (themes.keys().length === 1) {
thematicMaps = "map " + Utils.commasAnd(themes.keys())
let thematicMaps = " with the thematic maps " + Utils.commasAnd(themes.keys())
if (this._config?.themeWhitelist?.length === 1) {
thematicMaps = ""
} else if (themes.keys().length === 1) {
thematicMaps = " with the thematic map " + Utils.commasAnd(themes.keys())
}
return username + " " + statistics.summaryText + " with the thematic " + thematicMaps
return username + " " + statistics.summaryText + thematicMaps
}
async createOverviewForTheme(theme: string, changesetsMade: ChangeSetData[]): Promise<string> {
@ -206,8 +225,21 @@ export class Postbuilder {
public async buildMessage(date: string): Promise<void> {
const changesets = this._changesetsMade
const perContributor = new Histogram(changesets, cs => cs.properties.uid)
let lastPostId: string = undefined
if(this._config.report){
const report = this._config.report
const overpass = new Overpass(report)
const data = await overpass.query()
const total = data.elements.length
const date = data.osm3s.timestamp_osm_base.substring(0, 10)
lastPostId = (await this._poster.writeMessage(
report.post.replace(/{total}/g, ""+total).replace(/{date}/g, date)
)).id
}
const perContributor = new Histogram(changesets, cs => cs.properties.uid)
const topContributors = perContributor.sortedByCount({
countMethod: cs => {
let sum = 0
@ -230,17 +262,25 @@ export class Postbuilder {
} = await this.prepareImages(changesets, 12)
let timePeriod = "Yesterday"
if(this._config.numberOfDays > 1){
timePeriod = "In the past "+this._config.numberOfDays+" days"
if (this._config.numberOfDays > 1) {
timePeriod = "In the past " + this._config.numberOfDays + " days"
}
const singleTheme = this._config?.themeWhitelist?.length === 1 ? "/" + this._config.themeWhitelist[0] : ""
let toSend: string[] = [
timePeriod+", " + perContributor.keys().length + " different persons made " + totalStats.total + " changes to #OpenStreetMap using https://mapcomplete.osm.be .\n",
`${timePeriod}, ${perContributor.keys().length} persons made ${totalStats.total} changes to #OpenStreetMap using https://mapcomplete.osm.be${singleTheme} .
`,
]
for (let i = 0; i < this._config.topContributorsNumberToShow - 1 && i < topContributors.length; i++) {
const uid = topContributors[i].key
if (this._config.showTopContributors && topContributors.length > 0) {
for (const topContributor of topContributors) {
const uid = topContributor.key
const changesetsMade = perContributor.get(uid)
try {
const userInfo = new OsmUserInfo(Number(uid))
const {nobot} = await userInfo.hasNoBotTag()
if (nobot) {
continue
}
const overview = await this.createOverviewForContributor(uid, changesetsMade)
if (overview.length + toSend.join("\n").length > 500) {
break
@ -249,11 +289,10 @@ export class Postbuilder {
} catch (e) {
console.error("Could not add contributor " + uid, e)
}
}
const firstPost = await this._poster.writeMessage(toSend.join("\n"), {mediaIds: attachmentIds.slice(0, 4)})
lastPostId = (await this._poster.writeMessage(toSend.join("\n"), {mediaIds: attachmentIds.slice(0, 4)})).id
toSend = []
}
const perTheme = new Histogram(changesets, cs => {
return cs.properties.theme;
@ -263,17 +302,24 @@ export class Postbuilder {
countMethod: cs => this.getStatisticsFor([cs]).total,
dropZeroValues: true
})
toSend.push("")
for (let i = 0; i < this._config.topThemesNumberToShow && i < mostPopularThemes.length; i++) {
const theme = mostPopularThemes[i].key
const changesetsMade = perTheme.get(theme)
toSend.push(await this.createOverviewForTheme(theme, changesetsMade))
if (this._config.showTopThemes && mostPopularThemes.length > 0) {
for (const theme of mostPopularThemes) {
const themeId = theme.key
const changesetsMade = perTheme.get(themeId)
const overview = await this.createOverviewForTheme(themeId, changesetsMade)
if (overview.length + toSend.join("\n").length > 500) {
break
}
toSend.push(overview)
}
const secondPost = await this._poster.writeMessage(toSend.join("\n"), {
inReplyToId: firstPost["id"],
lastPostId = (await this._poster.writeMessage(toSend.join("\n"), {
inReplyToId: lastPostId,
mediaIds: attachmentIds.slice(4, 8)
})
})).id
toSend = []
}
const authorNames = Array.from(new Set<string>(imgAuthors))
@ -282,10 +328,10 @@ export class Postbuilder {
"Images in this thread are randomly selected from them and were made by: ",
...authorNames.map(auth => "- " + auth),
"",
"All changes were made on " + date + (this._config.numberOfDays > 1 ? " or at most "+this._config.numberOfDays+"days before": "")
"All changes were made on " + date + (this._config.numberOfDays > 1 ? ` or at most ${this._config.numberOfDays} days before` : "")
].join("\n"), {
inReplyToId: secondPost["id"],
inReplyToId: lastPostId,
mediaIds: attachmentIds.slice(8, 12)
}
)
@ -301,6 +347,13 @@ export class Postbuilder {
const seenURLS = new Set<string>()
for (const changeset of withImage) {
const userinfo = new OsmUserInfo(Number(changeset.properties.uid))
const {nobot} = await userinfo.hasNoBotTag()
if (nobot) {
console.log("Not indexing images of user", changeset.properties.user)
continue
}
const url = this._globalConfig.osmBackend + "/api/0.6/changeset/" + changeset.id + "/download"
const osmChangeset = await Utils.DownloadXml(url)
const osmChangesetTags: { k: string, v: string }[] = Array.from(osmChangeset.getElementsByTagName("tag"))
@ -308,7 +361,7 @@ export class Postbuilder {
.filter(kv => kv.k.startsWith("image"))
for (const kv of osmChangesetTags) {
if(seenURLS.has(kv.v)){
if (seenURLS.has(kv.v)) {
continue
}
seenURLS.add(kv.v)
@ -325,8 +378,8 @@ export class Postbuilder {
let authorName = cs.user
try {
const authorInfo = new OsmUserInfo(Number(cs.uid), this._globalConfig)
authorName = (await authorInfo.GetMastodonLink()) ?? cs.user
}catch (e) {
authorName = (await authorInfo.GetMastodonUsername(this._poster)) ?? cs.user
} catch (e) {
console.log("Could not fetch more info about contributor", authorName, cs.uid, "due to", e)
}
imgAuthors.push(authorName)

View file

@ -5,7 +5,13 @@ import {DOMParser} from '@xmldom/xmldom'
export default class Utils {
public static async DownloadJson(url, headers?: any): Promise<any> {
const data = await Utils.Download(url, headers)
try{
return JSON.parse(data.content)
}catch (e) {
console.log("Could not parse the result of ", url,": not a valid json:\n ",data.content)
throw e
}
}
public static Sum(t: (number | undefined)[]) {

View file

@ -17,6 +17,7 @@ export class Main {
}
async main() {
const poster = await MastodonPoster.construct(this._config.mastodonAuth)
if (fakedom === undefined || window === undefined) {
throw "FakeDom not initialized"
@ -27,7 +28,6 @@ export class Main {
console.log("Created the caching directory at", this._config.cacheDir)
}
const poster = await MastodonPoster.construct(this._config.mastodonAuth)
const notice = await poster.writeMessage("@pietervdvn@en.osm.town Starting MapComplete bot...", {
visibility: "direct"
@ -42,7 +42,7 @@ export class Main {
const end = Date.now()
const timeNeeded = Math.floor((end - start) / 1000)
await poster.writeMessage("Finished running MapComplete bot, this took " + timeNeeded + "seconds", {
await poster.writeMessage("@pietervdvn@en.osm.town Finished running MapComplete bot, this took " + timeNeeded + "seconds", {
inReplyToId: notice.id,
visibility: "direct"
})
@ -50,7 +50,7 @@ export class Main {
console.error(e)
const end = Date.now()
const timeNeeded = Math.floor((end - start) / 1000)
await poster.writeMessage("Running MapComplete bot failed in " + timeNeeded + "seconds, the error is " + e, {
await poster.writeMessage("@pietervdvn@en.osm.town Running MapComplete bot failed in " + timeNeeded + "seconds, the error is " + e, {
inReplyToId: notice.id,
visibility: "direct"
})
@ -58,14 +58,13 @@ export class Main {
}
private async runMapCompleteOverviewAction(poster: MastodonPoster, action: { mapCompleteUsageOverview: MapCompleteUsageOverview }) {
private async runMapCompleteOverviewAction(poster: MastodonPoster, action: MapCompleteUsageOverview) {
console.log("Fetching recent changesets...")
const osmcha = new OsmCha(this._config)
const today = new Date()
const overviewSettings = action.mapCompleteUsageOverview
let changesets: ChangeSetData[] = []
const days = overviewSettings.numberOfDays ?? 1
const days = action.numberOfDays ?? 1
if (days < 1) {
throw new Error("Invalid config: numberOfDays should be >= 1")
}
@ -82,19 +81,19 @@ export class Main {
}
if (overviewSettings.themeWhitelist?.length > 0) {
const allowedThemes = new Set(overviewSettings.themeWhitelist)
if (action.themeWhitelist?.length > 0) {
const allowedThemes = new Set(action.themeWhitelist)
const beforeCount = changesets.length
changesets = changesets.filter(cs => allowedThemes.has(cs.properties.theme))
if (changesets.length == 0) {
console.log("No changesets found for themes", overviewSettings.themeWhitelist.join(", "))
return console.log("No changesets found for themes", overviewSettings.themeWhitelist.join(", "))
console.log("No changesets found for themes", action.themeWhitelist.join(", "))
return console.log("No changesets found for themes", action.themeWhitelist.join(", "))
}
console.log("Filtering for ", overviewSettings.themeWhitelist, "yielded", changesets.length, "changesets (" + beforeCount + " before)")
console.log("Filtering for ", action.themeWhitelist, "yielded", changesets.length, "changesets (" + beforeCount + " before)")
}
console.log("Building post...")
await new Postbuilder(overviewSettings, this._config, poster, changesets).buildMessage(today.getUTCFullYear() + "-" + Utils.TwoDigits(today.getUTCMonth() + 1) + "-" + Utils.TwoDigits(today.getUTCDate() - 1))
await new Postbuilder(action, this._config, poster, changesets).buildMessage(today.getUTCFullYear() + "-" + Utils.TwoDigits(today.getUTCMonth() + 1) + "-" + Utils.TwoDigits(today.getUTCDate() - 1))
}