Fork of upstream llblab/[email protected].0 (SHA afe09c5) repackaged as a drop-in extension for can1357/oh-my-pi (omp >= 16.5.2). Changes vs upstream: - Rename package: @llblab/pi-telegram -> @evandrodevbr/oh-my-pi-telegram - Bump version: 0.22.0 -> 0.22.0-evandro.1 - Update repository/homepage/bugs URLs to evandrodevbr - Add omp field in package.json (displayName + compatibleWith) - README: prepend 'Oh My Pi (omp) Compatibility' section with install commands, verified-components table, upstream lineage, and rationale - CHANGELOG: add '0.22.0-evandro.1' entry under Unreleased - docs/: rename @llblab/pi-telegram imports to @evandrodevbr/oh-my-pi-telegram - tests/public-api.test.ts: rename package import paths Gates re-run on the fork: - npm run typecheck -> tsc --noEmit (success) - npm test -> 1229 pass / 0 fail / 1 platform-only skip - npm run audit -> found 0 vulnerabilities - npm run pack:check -> evandrodevbr-oh-my-pi-telegram-0.22.0-evandro.1.tgz Verified: - omp install --link ./pi-telegram -> Linked OK - omp plugin list -> @evandrodevbr/[email protected].0-evandro.1 Upstream lineage: badlogic/pi-telegram -> llblab/pi-telegram (0.22.0) -> evandrodevbr/oh-my-pi-telegram (this fork)
264 lines
7.4 KiB
TypeScript
264 lines
7.4 KiB
TypeScript
/**
|
|
* Telegram message ownership helpers
|
|
* Zones: telegram routing, multi-instance bus, in-memory coordination
|
|
* Owns live message-id ownership lookup so callbacks/reactions can resolve a Telegram UI surface back to the instance/target that produced it
|
|
*/
|
|
|
|
import { getTelegramTargetKey, type TelegramTarget } from "./target.ts";
|
|
|
|
export interface TelegramMessageOwnershipRecord {
|
|
chatId: number;
|
|
messageId: number;
|
|
target: TelegramTarget;
|
|
instanceId: string;
|
|
profileKey?: string;
|
|
ownerGeneration?: string;
|
|
createdAt: number;
|
|
updatedAt: number;
|
|
}
|
|
|
|
export interface TelegramMessageOwnershipStore {
|
|
record: (input: {
|
|
chatId: number;
|
|
messageId: number;
|
|
target?: TelegramTarget;
|
|
instanceId: string;
|
|
profileKey?: string;
|
|
ownerGeneration?: string;
|
|
now?: number;
|
|
}) => TelegramMessageOwnershipRecord;
|
|
get: (
|
|
chatId: number,
|
|
messageId: number,
|
|
) => TelegramMessageOwnershipRecord | undefined;
|
|
forget: (chatId: number, messageId: number) => boolean;
|
|
forgetTarget: (target: TelegramTarget) => number;
|
|
prune: (options: {
|
|
now: number;
|
|
maxAgeMs?: number;
|
|
maxRecords?: number;
|
|
}) => number;
|
|
entries: () => TelegramMessageOwnershipRecord[];
|
|
clear: () => void;
|
|
}
|
|
|
|
export interface TelegramFollowerOwnershipView {
|
|
instanceId: string;
|
|
connectedAtMs: number;
|
|
registrationGeneration?: string;
|
|
}
|
|
|
|
export interface TelegramBusMessageOwnershipRuntime {
|
|
store: TelegramMessageOwnershipStore;
|
|
recordLocal(input: {
|
|
chatId: number;
|
|
messageId: number;
|
|
target?: TelegramTarget;
|
|
}): TelegramMessageOwnershipRecord;
|
|
recordRouted(input: {
|
|
chatId: number;
|
|
messageId: number;
|
|
target?: TelegramTarget;
|
|
instanceId: string;
|
|
}): TelegramMessageOwnershipRecord;
|
|
recordFollower(input: {
|
|
chatId: number;
|
|
messageId: number;
|
|
target?: TelegramTarget;
|
|
follower: TelegramFollowerOwnershipView;
|
|
}): TelegramMessageOwnershipRecord;
|
|
isOwnedByFollower(input: {
|
|
chatId: number;
|
|
messageId: number;
|
|
follower: TelegramFollowerOwnershipView;
|
|
}): boolean;
|
|
}
|
|
|
|
function getTelegramFollowerOwnershipGeneration(
|
|
follower: TelegramFollowerOwnershipView,
|
|
): string {
|
|
return (
|
|
follower.registrationGeneration ??
|
|
`${follower.instanceId}:${follower.connectedAtMs}`
|
|
);
|
|
}
|
|
|
|
export function createTelegramBusMessageOwnershipRuntime(deps: {
|
|
instanceId: string;
|
|
getProfileKey(): string;
|
|
listFollowers(): readonly TelegramFollowerOwnershipView[];
|
|
}): TelegramBusMessageOwnershipRuntime {
|
|
const store = createTelegramMessageOwnershipStore({
|
|
getProfileKey: deps.getProfileKey,
|
|
isOwnerGenerationLive(record) {
|
|
if (!record.ownerGeneration) return true;
|
|
return deps.listFollowers().some((follower) => {
|
|
return (
|
|
follower.instanceId === record.instanceId &&
|
|
getTelegramFollowerOwnershipGeneration(follower) ===
|
|
record.ownerGeneration
|
|
);
|
|
});
|
|
},
|
|
});
|
|
const recordFollower = function (input: {
|
|
chatId: number;
|
|
messageId: number;
|
|
target?: TelegramTarget;
|
|
follower: TelegramFollowerOwnershipView;
|
|
}): TelegramMessageOwnershipRecord {
|
|
return store.record({
|
|
chatId: input.chatId,
|
|
messageId: input.messageId,
|
|
target: input.target,
|
|
instanceId: input.follower.instanceId,
|
|
ownerGeneration: getTelegramFollowerOwnershipGeneration(input.follower),
|
|
});
|
|
};
|
|
return {
|
|
store,
|
|
recordLocal(input) {
|
|
return store.record({ ...input, instanceId: deps.instanceId });
|
|
},
|
|
recordRouted(input) {
|
|
const follower = deps
|
|
.listFollowers()
|
|
.find((candidate) => candidate.instanceId === input.instanceId);
|
|
return follower
|
|
? recordFollower({ ...input, follower })
|
|
: store.record(input);
|
|
},
|
|
recordFollower,
|
|
isOwnedByFollower({ chatId, messageId, follower }) {
|
|
const ownership = store.get(chatId, messageId);
|
|
return (
|
|
ownership?.instanceId === follower.instanceId &&
|
|
ownership.ownerGeneration ===
|
|
getTelegramFollowerOwnershipGeneration(follower)
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
function getTelegramMessageOwnershipKey(
|
|
chatId: number,
|
|
messageId: number,
|
|
profileKey?: string,
|
|
): string {
|
|
return `${profileKey ?? ""}:${chatId}:${messageId}`;
|
|
}
|
|
|
|
function createTelegramMessageOwnershipRecord(input: {
|
|
chatId: number;
|
|
messageId: number;
|
|
target?: TelegramTarget;
|
|
instanceId: string;
|
|
profileKey?: string;
|
|
ownerGeneration?: string;
|
|
now: number;
|
|
previous?: TelegramMessageOwnershipRecord;
|
|
}): TelegramMessageOwnershipRecord {
|
|
return {
|
|
chatId: input.chatId,
|
|
messageId: input.messageId,
|
|
target: input.target ?? { chatId: input.chatId },
|
|
instanceId: input.instanceId,
|
|
...(input.profileKey ? { profileKey: input.profileKey } : {}),
|
|
...(input.ownerGeneration
|
|
? { ownerGeneration: input.ownerGeneration }
|
|
: {}),
|
|
createdAt: input.previous?.createdAt ?? input.now,
|
|
updatedAt: input.now,
|
|
};
|
|
}
|
|
|
|
export function createTelegramMessageOwnershipStore(
|
|
options: {
|
|
getProfileKey?: () => string | undefined;
|
|
isOwnerGenerationLive?: (record: TelegramMessageOwnershipRecord) => boolean;
|
|
} = {},
|
|
): TelegramMessageOwnershipStore {
|
|
const records = new Map<string, TelegramMessageOwnershipRecord>();
|
|
return {
|
|
record: (input) => {
|
|
const profileKey = input.profileKey ?? options.getProfileKey?.();
|
|
const key = getTelegramMessageOwnershipKey(
|
|
input.chatId,
|
|
input.messageId,
|
|
profileKey,
|
|
);
|
|
const now = input.now ?? Date.now();
|
|
const record = createTelegramMessageOwnershipRecord({
|
|
...input,
|
|
profileKey,
|
|
now,
|
|
previous: records.get(key),
|
|
});
|
|
records.set(key, record);
|
|
return record;
|
|
},
|
|
get: (chatId, messageId) => {
|
|
const record = records.get(
|
|
getTelegramMessageOwnershipKey(
|
|
chatId,
|
|
messageId,
|
|
options.getProfileKey?.(),
|
|
),
|
|
);
|
|
if (!record) return undefined;
|
|
if (
|
|
record.ownerGeneration &&
|
|
options.isOwnerGenerationLive &&
|
|
!options.isOwnerGenerationLive(record)
|
|
) {
|
|
return undefined;
|
|
}
|
|
return record;
|
|
},
|
|
forget: (chatId, messageId) =>
|
|
records.delete(
|
|
getTelegramMessageOwnershipKey(
|
|
chatId,
|
|
messageId,
|
|
options.getProfileKey?.(),
|
|
),
|
|
),
|
|
forgetTarget: (target) => {
|
|
const targetKey = getTelegramTargetKey(target);
|
|
const profileKey = options.getProfileKey?.();
|
|
let removed = 0;
|
|
for (const [key, record] of records) {
|
|
if ((record.profileKey ?? undefined) !== profileKey) continue;
|
|
if (getTelegramTargetKey(record.target) !== targetKey) continue;
|
|
records.delete(key);
|
|
removed += 1;
|
|
}
|
|
return removed;
|
|
},
|
|
prune: ({ now, maxAgeMs, maxRecords }) => {
|
|
let removed = 0;
|
|
if (maxAgeMs !== undefined) {
|
|
for (const [key, record] of records) {
|
|
if (now - record.updatedAt <= maxAgeMs) continue;
|
|
records.delete(key);
|
|
removed += 1;
|
|
}
|
|
}
|
|
if (maxRecords !== undefined && records.size > maxRecords) {
|
|
const oldest = [...records.entries()].sort(
|
|
(left, right) => left[1].updatedAt - right[1].updatedAt,
|
|
);
|
|
for (const [key] of oldest.slice(0, records.size - maxRecords)) {
|
|
records.delete(key);
|
|
removed += 1;
|
|
}
|
|
}
|
|
return removed;
|
|
},
|
|
entries: () => [...records.values()],
|
|
clear: () => {
|
|
records.clear();
|
|
},
|
|
};
|
|
}
|