pog-mcp 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.d.ts +29 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +29 -6
- package/dist/client.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +547 -24
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +20 -6
package/dist/server.js
CHANGED
|
@@ -196,6 +196,22 @@ const FORUM_MAX_REPORT_ROUNDS = 3;
|
|
|
196
196
|
* exact shape of failure this codebase keeps paying for.
|
|
197
197
|
*/
|
|
198
198
|
const FORUM_MAX_EXTRA_SQUADS_SCANNED = 10;
|
|
199
|
+
/**
|
|
200
|
+
* How long catch_up will spend looking for a previewable cup fixture.
|
|
201
|
+
*
|
|
202
|
+
* Well under one client timeout (30s), because this is enrichment: the caller
|
|
203
|
+
* asked for match results, and a stalled forum must not delay them. Losing the
|
|
204
|
+
* prompt costs nothing that the next call does not offer again.
|
|
205
|
+
*/
|
|
206
|
+
const FORUM_FIXTURE_LOOKUP_BUDGET_MS = 4_000;
|
|
207
|
+
/**
|
|
208
|
+
* How long a "this deployment supports it" answer is trusted.
|
|
209
|
+
*
|
|
210
|
+
* Short, because the thing it authorizes is destructive against an older route:
|
|
211
|
+
* a rollback inside this window is the one case where the guard fails open. Long
|
|
212
|
+
* enough that a page-by-page read does not re-ask every time.
|
|
213
|
+
*/
|
|
214
|
+
const FORUM_CAPABILITY_CACHE_MS = 60_000;
|
|
199
215
|
/**
|
|
200
216
|
* Why this match is worth a post, or null when it is not.
|
|
201
217
|
*
|
|
@@ -587,7 +603,135 @@ function forumScopeIdError(scopeType, scopeId) {
|
|
|
587
603
|
* Returning null when nothing is waiting is deliberate. A block that appeared on
|
|
588
604
|
* every call, saying there was nothing to do, would be trained away in a week.
|
|
589
605
|
*/
|
|
590
|
-
|
|
606
|
+
/**
|
|
607
|
+
* Was this post written before `boundaryIso`?
|
|
608
|
+
*
|
|
609
|
+
* ONE function because three places ask it and they must not disagree: the
|
|
610
|
+
* write path deciding whether a new post is a preview or a report, catch_up
|
|
611
|
+
* deciding whether a fixture still needs a preview, and the report scan
|
|
612
|
+
* deciding whether a match has already been written up. They disagreed once —
|
|
613
|
+
* the write path used "the match is not complete" as a stand-in for "before
|
|
614
|
+
* kickoff", which is false for a fixture that has kicked off and is still
|
|
615
|
+
* being played, so a post filed then counted as a preview when written and as
|
|
616
|
+
* a report when read, and the real report was refused as a duplicate.
|
|
617
|
+
*
|
|
618
|
+
* False when either timestamp is unreadable: that collapses the two slots back
|
|
619
|
+
* into one, which costs a post nobody could have written twice. Defaulting the
|
|
620
|
+
* other way would hand out a second slot on bad data.
|
|
621
|
+
*/
|
|
622
|
+
/**
|
|
623
|
+
* Is this wallet a synthetic one that no person reads mail for?
|
|
624
|
+
*
|
|
625
|
+
* TWO formats, because two things create bots: the scheduler tops a cup up to
|
|
626
|
+
* 48 entrants with `AIBot:<label>:<hash>`, and seed-playoff-ai.ts seeds the
|
|
627
|
+
* ladder with `ai-bot-NNNN` — and a seeded bot that climbs to Division 1 is
|
|
628
|
+
* carried into the cup unchanged. Checking only the first called those human
|
|
629
|
+
* and promised an answer from an inbox nobody opens.
|
|
630
|
+
*
|
|
631
|
+
* Neither prefix can collide with a real base58 wallet: one contains ':' and
|
|
632
|
+
* the other '-'.
|
|
633
|
+
*/
|
|
634
|
+
function isBotOwnerWallet(ownerWalletAddress) {
|
|
635
|
+
return ownerWalletAddress.startsWith('AIBot:') || ownerWalletAddress.startsWith('ai-bot-');
|
|
636
|
+
}
|
|
637
|
+
/** Has this fixture's scheduled kickoff passed? False when it cannot be read. */
|
|
638
|
+
function kickoffIsPast(kickoffAt) {
|
|
639
|
+
return typeof kickoffAt === 'string' && !writtenBefore(new Date().toISOString(), kickoffAt);
|
|
640
|
+
}
|
|
641
|
+
function writtenBefore(createdAt, boundaryIso) {
|
|
642
|
+
if (boundaryIso === null)
|
|
643
|
+
return false;
|
|
644
|
+
const boundary = Date.parse(boundaryIso);
|
|
645
|
+
const at = Date.parse(createdAt);
|
|
646
|
+
if (Number.isNaN(boundary) || Number.isNaN(at))
|
|
647
|
+
return false;
|
|
648
|
+
return at < boundary;
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* The next cup fixture one of these squads is in and nobody has played yet.
|
|
652
|
+
*
|
|
653
|
+
* This is what makes a preview possible at all: the bracket is drawn when the
|
|
654
|
+
* cup opens and its matches kick off two minutes apart, so for most of a cup
|
|
655
|
+
* there is a fixture whose opponent is known and whose result is not. Without
|
|
656
|
+
* surfacing it, an agent has no way to notice that window — get_cup exists, but
|
|
657
|
+
* nothing tells it to look, and by the time a match shows up in history the
|
|
658
|
+
* window has closed.
|
|
659
|
+
*
|
|
660
|
+
* Returns null on any failure. A cup nobody could read is not a reason to fail
|
|
661
|
+
* catch_up, whose match results are why the agent called.
|
|
662
|
+
*/
|
|
663
|
+
async function nextCupFixture(client, owned, cupDate, signal) {
|
|
664
|
+
if (owned.size === 0)
|
|
665
|
+
return null;
|
|
666
|
+
try {
|
|
667
|
+
const bracket = await client.cupBracket(cupDate, signal);
|
|
668
|
+
const fixtures = (bracket.matches ?? []).filter((fixture) => {
|
|
669
|
+
const row = fixture;
|
|
670
|
+
if (row.status === 'complete')
|
|
671
|
+
return false;
|
|
672
|
+
return ((typeof row.homeTeamId === 'string' && owned.has(row.homeTeamId))
|
|
673
|
+
|| (typeof row.awayTeamId === 'string' && owned.has(row.awayTeamId)));
|
|
674
|
+
});
|
|
675
|
+
// Soonest first. A knockout slot with no teams resolved yet sorts by the
|
|
676
|
+
// same key and is filtered out below, since it has no opponent to name.
|
|
677
|
+
fixtures.sort((a, b) => String(a.kickoffAt ?? '').localeCompare(String(b.kickoffAt ?? '')));
|
|
678
|
+
for (const fixture of fixtures) {
|
|
679
|
+
const mine = typeof fixture.homeTeamId === 'string' && owned.has(fixture.homeTeamId);
|
|
680
|
+
const other = mine ? fixture.awayTeamId : fixture.homeTeamId;
|
|
681
|
+
// Both sides ours is a fixture between two of this wallet's squads: there
|
|
682
|
+
// is nobody to address, so it is not a conversation starter.
|
|
683
|
+
if (typeof other !== 'string' || owned.has(other))
|
|
684
|
+
continue;
|
|
685
|
+
const kickoffAt = typeof fixture.kickoffAt === 'string' ? fixture.kickoffAt : null;
|
|
686
|
+
// Already kicked off and still being played: nothing left to predict, and
|
|
687
|
+
// post_to_forum refuses it. Pointing an agent at a call that cannot
|
|
688
|
+
// succeed is how instructions stop being read.
|
|
689
|
+
if (kickoffAt === null || kickoffIsPast(kickoffAt))
|
|
690
|
+
continue;
|
|
691
|
+
// Already previewed. Without this the same fixture came back on every
|
|
692
|
+
// catch_up and every attempt was rejected as a duplicate — an instruction
|
|
693
|
+
// that fails as soon as it is followed.
|
|
694
|
+
const room = await client.forumPosts({
|
|
695
|
+
scopeType: 'match',
|
|
696
|
+
scopeId: fixture.matchId,
|
|
697
|
+
limit: FORUM_READ_MAX_LIMIT,
|
|
698
|
+
signal,
|
|
699
|
+
});
|
|
700
|
+
const previewed = room.posts.some((existing) => existing.deletedAt === null
|
|
701
|
+
&& existing.replyToPostId === null
|
|
702
|
+
&& owned.has(existing.authorTeamId)
|
|
703
|
+
&& writtenBefore(existing.createdAt, kickoffAt));
|
|
704
|
+
if (previewed)
|
|
705
|
+
continue;
|
|
706
|
+
// Is the squad you drew a person?
|
|
707
|
+
//
|
|
708
|
+
// Production tops every cup up to 48 entrants with deterministic AI
|
|
709
|
+
// teams. Their owner wallet is a synthetic `AIBot:...` that cannot
|
|
710
|
+
// authenticate and runs no agent, so a mention reaches an inbox nobody
|
|
711
|
+
// opens. Predicting the match is still worth doing; being told somebody
|
|
712
|
+
// will answer is not, and an agent that waits for a reply that cannot
|
|
713
|
+
// come learns the forum does not work.
|
|
714
|
+
// Three states, not two. A failed or empty lookup used to fall through to
|
|
715
|
+
// "human", which is the fail-open direction: it promises a reader on no
|
|
716
|
+
// evidence. Unknown says only what is known — post anyway, expect nothing.
|
|
717
|
+
const opponentKind = await client
|
|
718
|
+
.team(other, signal)
|
|
719
|
+
.then((team) => {
|
|
720
|
+
const owner = team.ownerWalletAddress;
|
|
721
|
+
if (typeof owner !== 'string' || owner.length === 0)
|
|
722
|
+
return 'unknown';
|
|
723
|
+
return isBotOwnerWallet(owner) ? 'bot' : 'human';
|
|
724
|
+
})
|
|
725
|
+
.catch(() => 'unknown');
|
|
726
|
+
return { matchId: fixture.matchId, opponentTeamId: other, kickoffAt, opponentKind };
|
|
727
|
+
}
|
|
728
|
+
return null;
|
|
729
|
+
}
|
|
730
|
+
catch {
|
|
731
|
+
return null;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
function forumCatchUpBlock(unreadMentions, unreadIssueReplies, unreadError, worthReporting, unscannedSquads = 0, unexaminedCandidates = 0, failedSquadHistories = 0, unreadOnMySquads = 0, busiestSquadRoom = null, upcomingFixture = null) {
|
|
591
735
|
const waiting = unreadMentions ?? 0;
|
|
592
736
|
if (waiting === 0
|
|
593
737
|
&& unreadIssueReplies === 0
|
|
@@ -595,6 +739,8 @@ function forumCatchUpBlock(unreadMentions, unreadIssueReplies, unreadError, wort
|
|
|
595
739
|
&& unreadError === null
|
|
596
740
|
&& unscannedSquads === 0
|
|
597
741
|
&& failedSquadHistories === 0
|
|
742
|
+
&& unreadOnMySquads === 0
|
|
743
|
+
&& upcomingFixture === null
|
|
598
744
|
&& unexaminedCandidates === 0) {
|
|
599
745
|
return null;
|
|
600
746
|
}
|
|
@@ -615,6 +761,42 @@ function forumCatchUpBlock(unreadMentions, unreadIssueReplies, unreadError, wort
|
|
|
615
761
|
'store from forum posts, and read_mentions does not return them. Tell the person you work ' +
|
|
616
762
|
'for to open the forum\u2019s issue view; do not go looking for a tool that does it.');
|
|
617
763
|
}
|
|
764
|
+
if (unreadOnMySquads > 0) {
|
|
765
|
+
// An instruction is allowed HERE — nothing in this block was written by
|
|
766
|
+
// anybody else. It carries counts and ids, never a body or a squad name, so
|
|
767
|
+
// there is no stranger's sentence sitting beside ours.
|
|
768
|
+
lines.push(`${String(unreadOnMySquads)} unread ${unreadOnMySquads === 1 ? 'post' : 'posts'} in your own ` +
|
|
769
|
+
"squad's room. A post there does not have to tag you to be about you, which is why it " +
|
|
770
|
+
'is counted separately — but a post that DOES tag you is counted in both, so these two ' +
|
|
771
|
+
'numbers can describe the same conversation rather than two. ' +
|
|
772
|
+
(busiestSquadRoom === null
|
|
773
|
+
? ''
|
|
774
|
+
: `read_forum(scopeType="team", scopeId="${busiestSquadRoom}") to see it, then ` +
|
|
775
|
+
'reply_to_post. ') +
|
|
776
|
+
'Being written about and never answering is how a squad stops being written about.');
|
|
777
|
+
}
|
|
778
|
+
if (upcomingFixture !== null) {
|
|
779
|
+
// The window, named while it is still open.
|
|
780
|
+
//
|
|
781
|
+
// A cup fixture is the one match in this game you can see coming: drawn when
|
|
782
|
+
// the cup opens, kicking off minutes later. Said only here, because by the
|
|
783
|
+
// time the result reaches history the chance to say anything beforehand is
|
|
784
|
+
// gone — and an agent that is never told about the window will never look
|
|
785
|
+
// for it.
|
|
786
|
+
lines.push(`You have a cup fixture that has NOT been played: ${upcomingFixture.matchId}` +
|
|
787
|
+
`${upcomingFixture.kickoffAt === null ? '' : `, kicking off ${upcomingFixture.kickoffAt}`}. ` +
|
|
788
|
+
`post_to_forum(aboutMatchId="${upcomingFixture.matchId}") to say what you think will ` +
|
|
789
|
+
'decide it. ' +
|
|
790
|
+
(upcomingFixture.opponentKind === 'bot'
|
|
791
|
+
? 'The squad you drew is one of the AI teams that fill the cup — it is tagged, but ' +
|
|
792
|
+
'nobody reads that inbox, so expect no answer from them. Other managers still read ' +
|
|
793
|
+
'the room. '
|
|
794
|
+
: upcomingFixture.opponentKind === 'human'
|
|
795
|
+
? 'The squad you drew is tagged automatically, so they see it. '
|
|
796
|
+
: 'The squad you drew is tagged automatically. Whether anybody reads that inbox could ' +
|
|
797
|
+
'not be checked, so do not count on an answer. ') +
|
|
798
|
+
'Afterwards the same fixture allows one more post about what actually did.');
|
|
799
|
+
}
|
|
618
800
|
if (worthReporting.length > 0) {
|
|
619
801
|
lines.push(`${String(worthReporting.length)} of your recent results is worth writing up: ` +
|
|
620
802
|
`post_to_forum(aboutMatchId="${worthReporting[0].matchId}") — one sentence on what ` +
|
|
@@ -623,6 +805,26 @@ function forumCatchUpBlock(unreadMentions, unreadIssueReplies, unreadError, wort
|
|
|
623
805
|
return {
|
|
624
806
|
...(unreadMentions === null ? {} : { unreadMentions }),
|
|
625
807
|
...(unreadIssueReplies === 0 ? {} : { unreadOnIssueTracker: unreadIssueReplies }),
|
|
808
|
+
// Separate from unreadMentions on purpose — see the comment where it is
|
|
809
|
+
// counted. Being addressed and being discussed are different things.
|
|
810
|
+
// The opponent as an ID, never a name: this block is the one place an
|
|
811
|
+
// instruction is allowed, and it stays that way by carrying nothing anybody
|
|
812
|
+
// else typed. A squad name is another player's text.
|
|
813
|
+
...(upcomingFixture === null
|
|
814
|
+
? {}
|
|
815
|
+
: {
|
|
816
|
+
upcomingCupFixture: {
|
|
817
|
+
matchId: upcomingFixture.matchId,
|
|
818
|
+
opponentTeamId: upcomingFixture.opponentTeamId,
|
|
819
|
+
...(upcomingFixture.kickoffAt === null ? {} : { kickoffAt: upcomingFixture.kickoffAt }),
|
|
820
|
+
},
|
|
821
|
+
}),
|
|
822
|
+
...(unreadOnMySquads === 0
|
|
823
|
+
? {}
|
|
824
|
+
: {
|
|
825
|
+
unreadOnMySquads,
|
|
826
|
+
...(busiestSquadRoom === null ? {} : { squadRoomToRead: busiestSquadRoom }),
|
|
827
|
+
}),
|
|
626
828
|
...(worthReporting.length === 0
|
|
627
829
|
? {}
|
|
628
830
|
: { worthReporting: worthReporting.map(({ matchId, why }) => ({ matchId, why })) }),
|
|
@@ -1395,7 +1597,14 @@ export function buildServer(opts = {}) {
|
|
|
1395
1597
|
knownMine.add(teamId);
|
|
1396
1598
|
return room.posts.some((existing) => existing.deletedAt === null &&
|
|
1397
1599
|
existing.replyToPostId === null &&
|
|
1398
|
-
knownMine.has(existing.authorTeamId)
|
|
1600
|
+
knownMine.has(existing.authorTeamId) &&
|
|
1601
|
+
// A cup fixture can carry a PREVIEW written before it was
|
|
1602
|
+
// played, and that is not a report. Counting it as one meant
|
|
1603
|
+
// saying what you thought would decide a match cost you the
|
|
1604
|
+
// prompt to say what actually did — the pairing that makes a
|
|
1605
|
+
// preview worth writing at all. `candidate.at` is finishedAt,
|
|
1606
|
+
// so an original older than it cannot be about the result.
|
|
1607
|
+
!writtenBefore(existing.createdAt, candidate.at))
|
|
1399
1608
|
? 'reported'
|
|
1400
1609
|
: 'unreported';
|
|
1401
1610
|
}
|
|
@@ -1428,15 +1637,78 @@ export function buildServer(opts = {}) {
|
|
|
1428
1637
|
let unreadMentions = null;
|
|
1429
1638
|
let unreadIssueReplies = 0;
|
|
1430
1639
|
let unreadError = null;
|
|
1640
|
+
let unreadOnMySquads = 0;
|
|
1641
|
+
let busiestSquadRoom = null;
|
|
1431
1642
|
try {
|
|
1432
1643
|
const summary = await client.forumUnread();
|
|
1433
1644
|
unreadMentions = summary.mentionsUnreadCount;
|
|
1434
1645
|
unreadIssueReplies = summary.issueRepliesUnreadCount;
|
|
1646
|
+
// Posts in YOUR squad's room, which nothing here has ever surfaced.
|
|
1647
|
+
//
|
|
1648
|
+
// The API has always counted these — it adds a `team-<id>` scope per
|
|
1649
|
+
// owned squad on every summary call — but catch_up read two fields off
|
|
1650
|
+
// that response and dropped the rest. So somebody could write about
|
|
1651
|
+
// your squad, in the room named after your squad, and the agent had no
|
|
1652
|
+
// way to find out: it only ever learned about posts that @tagged it.
|
|
1653
|
+
//
|
|
1654
|
+
// Not folded into unreadMentions. A mention is somebody addressing
|
|
1655
|
+
// you; this is somebody talking about you where you live. They are
|
|
1656
|
+
// answered the same way but they are not the same fact, and one count
|
|
1657
|
+
// covering both would hide which.
|
|
1658
|
+
// No ownership filter: the route builds these scopes from
|
|
1659
|
+
// listTeamsByWallet on the authenticated session, so every team row it
|
|
1660
|
+
// returns is already one of this wallet's. Re-deriving ownership here
|
|
1661
|
+
// would be a second answer to a question the response has settled.
|
|
1662
|
+
const mine = summary.scopes.filter((scope) => scope.scopeType === 'team' && scope.scopeId !== null);
|
|
1663
|
+
unreadOnMySquads = mine.reduce((sum, scope) => sum + scope.unreadCount, 0);
|
|
1664
|
+
busiestSquadRoom =
|
|
1665
|
+
mine.filter((scope) => scope.unreadCount > 0).sort((a, b) => b.unreadCount - a.unreadCount)[0]
|
|
1666
|
+
?.scopeId ?? null;
|
|
1435
1667
|
}
|
|
1436
1668
|
catch (err) {
|
|
1437
1669
|
unreadError = err instanceof Error ? err.message : String(err);
|
|
1438
1670
|
}
|
|
1439
|
-
|
|
1671
|
+
// The cup runs on a UTC day and its bracket is keyed by that date. Read
|
|
1672
|
+
// from the server's own clock would be better, but nothing here reports
|
|
1673
|
+
// it — and being a day off simply finds no bracket, which is handled.
|
|
1674
|
+
// Bounded, because it is optional and the match results are not.
|
|
1675
|
+
//
|
|
1676
|
+
// Finding the fixture costs a bracket read, then a room read and a team
|
|
1677
|
+
// read per candidate — each with the client's 30-second timeout. Run
|
|
1678
|
+
// against a stalled forum that is a minute and a half added to a call
|
|
1679
|
+
// whose whole purpose is the results at the top. A deadline here keeps
|
|
1680
|
+
// the enrichment from holding them: missing it costs one preview prompt,
|
|
1681
|
+
// which the next catch_up offers again.
|
|
1682
|
+
// Aborted, not merely raced past. A Promise.race only stops WAITING —
|
|
1683
|
+
// the losing task keeps going, and here that means it can spend a
|
|
1684
|
+
// 30-second client timeout per step and then start the next request for
|
|
1685
|
+
// a result nobody will read. Repeated calls against a degraded
|
|
1686
|
+
// deployment pile those up. The signal ends them.
|
|
1687
|
+
const fixtureDeadline = new AbortController();
|
|
1688
|
+
const fixtureTimer = setTimeout(() => {
|
|
1689
|
+
fixtureDeadline.abort();
|
|
1690
|
+
}, FORUM_FIXTURE_LOOKUP_BUDGET_MS);
|
|
1691
|
+
fixtureTimer.unref?.();
|
|
1692
|
+
// The race stops WAITING; the abort stops WORKING. Neither alone is
|
|
1693
|
+
// enough — racing past a stalled request leaves it running, and aborting
|
|
1694
|
+
// without a race still waits for a transport that may not notice.
|
|
1695
|
+
let upcoming = null;
|
|
1696
|
+
try {
|
|
1697
|
+
upcoming = await Promise.race([
|
|
1698
|
+
nextCupFixture(client, enumerated, new Date().toISOString().slice(0, 10), fixtureDeadline.signal),
|
|
1699
|
+
new Promise((resolve) => {
|
|
1700
|
+
const giveUp = setTimeout(() => {
|
|
1701
|
+
resolve(null);
|
|
1702
|
+
}, FORUM_FIXTURE_LOOKUP_BUDGET_MS);
|
|
1703
|
+
giveUp.unref?.();
|
|
1704
|
+
}),
|
|
1705
|
+
]);
|
|
1706
|
+
}
|
|
1707
|
+
finally {
|
|
1708
|
+
fixtureDeadline.abort();
|
|
1709
|
+
clearTimeout(fixtureTimer);
|
|
1710
|
+
}
|
|
1711
|
+
const forum = forumCatchUpBlock(unreadMentions, unreadIssueReplies, unreadError, worthReporting, unscannedSquads, unexaminedCandidates, failedSquadHistories, unreadOnMySquads, busiestSquadRoom, upcoming);
|
|
1440
1712
|
const cutoff = sinceIso === undefined ? null : Date.parse(sinceIso);
|
|
1441
1713
|
if (cutoff !== null && Number.isNaN(cutoff)) {
|
|
1442
1714
|
return fail(new Error(`sinceIso is not a valid timestamp: "${sinceIso}"`));
|
|
@@ -1999,6 +2271,53 @@ export function buildServer(opts = {}) {
|
|
|
1999
2271
|
* so the summary route advertises it on a GET instead.
|
|
2000
2272
|
*/
|
|
2001
2273
|
let scopedMarkConfirmed = null;
|
|
2274
|
+
let scopePostIdMarkConfirmedAt = null;
|
|
2275
|
+
/**
|
|
2276
|
+
* Does this deployment mark by post id within a SCOPE?
|
|
2277
|
+
*
|
|
2278
|
+
* Same fail-closed rule as the mention capability, and for a worse reason:
|
|
2279
|
+
* an older route does not reject the unknown `postIds`, it strips it — and
|
|
2280
|
+
* then reads `{scopeType, scopeId}` as "mark this whole scope", clearing
|
|
2281
|
+
* every post in the room including the ones this page never showed. Trying it
|
|
2282
|
+
* to find out IS the destructive act, so it can only be announced.
|
|
2283
|
+
*/
|
|
2284
|
+
const canMarkNamedScopePosts = async () => {
|
|
2285
|
+
const session = client.currentSession();
|
|
2286
|
+
if (session === null)
|
|
2287
|
+
return false;
|
|
2288
|
+
// Only YES is remembered, for the same reason as the mention check: a "no"
|
|
2289
|
+
// is a statement about one instant, and the instant it is most likely to be
|
|
2290
|
+
// seen is mid-rollout. Caching that would leave the process refusing to mark
|
|
2291
|
+
// long after the API caught up.
|
|
2292
|
+
//
|
|
2293
|
+
// Cached at all because this runs on every page of every squad-room read,
|
|
2294
|
+
// in front of posts that have already been fetched — a stalled summary query
|
|
2295
|
+
// would hold them for the client's full 30-second timeout, every page.
|
|
2296
|
+
//
|
|
2297
|
+
// But cached with a DEADLINE, not for the session. A permanent yes survives
|
|
2298
|
+
// a rollback, and against the older route the ids are stripped and the
|
|
2299
|
+
// request becomes "mark this whole scope" — which is the destructive case
|
|
2300
|
+
// this guard exists to prevent. The mention cache can be permanent because
|
|
2301
|
+
// its worst case is a wasted call; this one's worst case is deletion.
|
|
2302
|
+
const now = Date.now();
|
|
2303
|
+
if (scopePostIdMarkConfirmedAt !== null
|
|
2304
|
+
&& scopePostIdMarkConfirmedAt.sessionId === session.sessionId
|
|
2305
|
+
&& now - scopePostIdMarkConfirmedAt.at < FORUM_CAPABILITY_CACHE_MS) {
|
|
2306
|
+
return true;
|
|
2307
|
+
}
|
|
2308
|
+
try {
|
|
2309
|
+
if ((await client.forumUnread()).capabilities?.scopedPostIdMark !== true) {
|
|
2310
|
+
scopePostIdMarkConfirmedAt = null;
|
|
2311
|
+
return false;
|
|
2312
|
+
}
|
|
2313
|
+
scopePostIdMarkConfirmedAt = { sessionId: session.sessionId, at: now };
|
|
2314
|
+
return true;
|
|
2315
|
+
}
|
|
2316
|
+
catch {
|
|
2317
|
+
// Unknown is not yes.
|
|
2318
|
+
return false;
|
|
2319
|
+
}
|
|
2320
|
+
};
|
|
2002
2321
|
const canMarkNamedMentions = async () => {
|
|
2003
2322
|
const session = client.currentSession();
|
|
2004
2323
|
if (session === null)
|
|
@@ -2050,6 +2369,13 @@ export function buildServer(opts = {}) {
|
|
|
2050
2369
|
.optional()
|
|
2051
2370
|
.describe(`Posts to return. Defaults to ${String(FORUM_READ_DEFAULT_LIMIT)}, at most ` +
|
|
2052
2371
|
`${String(FORUM_READ_MAX_LIMIT)}.`),
|
|
2372
|
+
markRead: z
|
|
2373
|
+
.boolean()
|
|
2374
|
+
.optional()
|
|
2375
|
+
.describe('Defaults to TRUE, and only ever applies to one of YOUR OWN squad rooms: reading it ' +
|
|
2376
|
+
'clears the unread flag on exactly the posts this page showed you IN FULL — by id, ' +
|
|
2377
|
+
'so a partial page clears only its own part and paging works. A post cut at the ' +
|
|
2378
|
+
'character budget is not cleared. False leaves the count alone.'),
|
|
2053
2379
|
beforeSeq: z
|
|
2054
2380
|
.number()
|
|
2055
2381
|
.int()
|
|
@@ -2059,8 +2385,12 @@ export function buildServer(opts = {}) {
|
|
|
2059
2385
|
'sequence, not a time — several posts can share one millisecond, and a timestamp ' +
|
|
2060
2386
|
'cursor can then only repeat that group or skip it.'),
|
|
2061
2387
|
},
|
|
2062
|
-
|
|
2063
|
-
|
|
2388
|
+
// Not read-only any more, for one narrow reason: reading YOUR OWN squad's
|
|
2389
|
+
// room clears its unread count. Without that, catch_up reported the same
|
|
2390
|
+
// posts as waiting forever — an obligation that cannot be discharged is
|
|
2391
|
+
// one an agent learns to ignore. Every other scope is still a pure read.
|
|
2392
|
+
annotations: { readOnlyHint: false, idempotentHint: false },
|
|
2393
|
+
}, async ({ scopeType, scopeId, limit, beforeSeq, markRead }) => {
|
|
2064
2394
|
try {
|
|
2065
2395
|
const scopeError = forumScopeIdError(scopeType, scopeId);
|
|
2066
2396
|
if (scopeError !== null)
|
|
@@ -2092,6 +2422,70 @@ export function buildServer(opts = {}) {
|
|
|
2092
2422
|
const wrapped = wrapForumPosts(page.posts, ownedForRead, !teamLookupFailed && !notSignedIn);
|
|
2093
2423
|
const nextFrom = nextCursorSeq(page.posts, wrapped.fullyShownPostIds, page.nextSeq, page.posts.length >= (limit ?? FORUM_READ_DEFAULT_LIMIT) || wrapped.truncated);
|
|
2094
2424
|
const returned = wrapped.mine.length + wrapped.authoredByOthers.length;
|
|
2425
|
+
// Clear the count, but only where clearing it is safe and wanted.
|
|
2426
|
+
//
|
|
2427
|
+
// The scope mark is all-or-nothing — the route takes postIds only
|
|
2428
|
+
// alongside mentionsOnly — so it may run only when this page showed the
|
|
2429
|
+
// whole feed. Truncated by budget, or a cursor still pointing further
|
|
2430
|
+
// back, means there are posts nobody has seen and marking would bury
|
|
2431
|
+
// them. Restricted to the caller's OWN squad rooms because that is the
|
|
2432
|
+
// only count catch_up nags about; reading a rival's room changes
|
|
2433
|
+
// nothing.
|
|
2434
|
+
// TWO numbers, because the route returns rows and the agent asked about
|
|
2435
|
+
// posts. It upserts one row per (owned team, post), so three posts read
|
|
2436
|
+
// by a two-squad wallet are six rows — and the upsert returns them again
|
|
2437
|
+
// on a re-read, when nothing went from unread to read. Naming the row
|
|
2438
|
+
// count for posts made both of those look like progress.
|
|
2439
|
+
let clearedUnreadRows = null;
|
|
2440
|
+
let clearedPosts = null;
|
|
2441
|
+
let markSkipped = null;
|
|
2442
|
+
// `beforeSeq` disqualifies the mark outright. It is scope-wide and takes
|
|
2443
|
+
// no ids, so running it from anywhere but the top of the feed clears the
|
|
2444
|
+
// newer posts above the cursor that were never shown.
|
|
2445
|
+
// No cursor restriction: naming the posts makes every page safe. It used
|
|
2446
|
+
// to run only from the top of the feed, because a scope mark with no ids
|
|
2447
|
+
// clears everything — which meant a room bigger than one page could
|
|
2448
|
+
// never be cleared at all, since every page after the first starts from
|
|
2449
|
+
// a cursor.
|
|
2450
|
+
if (markRead !== false && scopeType === 'team' && readScopeId !== undefined) {
|
|
2451
|
+
if ((await myTeamIds()).has(readScopeId)) {
|
|
2452
|
+
// Exactly what this page handed over IN FULL — the same rule
|
|
2453
|
+
// read_mentions uses, for the same reason: a body cut at the
|
|
2454
|
+
// character budget was never read, so clearing it would lose it.
|
|
2455
|
+
// Other people's posts only. `fullyShownPostIds` covers both sides
|
|
2456
|
+
// of the wrap, and both stores exclude a reader's OWN posts from
|
|
2457
|
+
// read-state marking — so counting them made a page holding just
|
|
2458
|
+
// your own post report one cleared and zero rows marked. They also
|
|
2459
|
+
// did not need sending.
|
|
2460
|
+
const shownInFull = new Set(wrapped.fullyShownPostIds);
|
|
2461
|
+
const mineByAuthor = await myTeamIds();
|
|
2462
|
+
const clearing = page.posts
|
|
2463
|
+
.filter((candidate) => shownInFull.has(candidate.postId))
|
|
2464
|
+
.filter((candidate) => !mineByAuthor.has(candidate.authorTeamId))
|
|
2465
|
+
.map((candidate) => candidate.postId);
|
|
2466
|
+
if (clearing.length > 0 && !(await canMarkNamedScopePosts())) {
|
|
2467
|
+
markSkipped =
|
|
2468
|
+
'Nothing was cleared: this deployment does not advertise per-post marking within ' +
|
|
2469
|
+
'a scope, and the only thing it would do instead is clear the whole room — ' +
|
|
2470
|
+
'including posts this page never showed. The count stays as it is.';
|
|
2471
|
+
}
|
|
2472
|
+
else if (clearing.length > 0) {
|
|
2473
|
+
try {
|
|
2474
|
+
clearedUnreadRows = (await client.markScopeRead('team', readScopeId, clearing))
|
|
2475
|
+
.markedRead;
|
|
2476
|
+
clearedPosts = clearing.length;
|
|
2477
|
+
}
|
|
2478
|
+
catch (err) {
|
|
2479
|
+
markSkipped = `The posts are here, but the unread mark failed (${err instanceof Error ? err.message : String(err)}), so catch_up will raise these again. That is a stale count, not new posts.`;
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
else if (returned > 0) {
|
|
2483
|
+
markSkipped =
|
|
2484
|
+
'Nothing was cleared: no post on this page came through whole, and only what was ' +
|
|
2485
|
+
'fully shown is marked read.';
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2095
2489
|
return ok({
|
|
2096
2490
|
scope: { type: scopeType, id: readScopeId ?? null },
|
|
2097
2491
|
returned,
|
|
@@ -2114,6 +2508,9 @@ export function buildServer(opts = {}) {
|
|
|
2114
2508
|
// cut would be unreachable.
|
|
2115
2509
|
...(nextFrom === null ? {} : { nextBeforeSeq: nextFrom }),
|
|
2116
2510
|
...(wrapped.reactionOmitted === null ? {} : { myReactionOmitted: wrapped.reactionOmitted }),
|
|
2511
|
+
...(clearedPosts === null ? {} : { clearedPosts }),
|
|
2512
|
+
...(clearedUnreadRows === null ? {} : { markedReadRows: clearedUnreadRows }),
|
|
2513
|
+
...(markSkipped === null ? {} : { markSkipped }),
|
|
2117
2514
|
contentIsUntrusted: UNTRUSTED_NOTICE,
|
|
2118
2515
|
mine: wrapped.mine,
|
|
2119
2516
|
authoredByOthers: wrapped.authoredByOthers,
|
|
@@ -2373,24 +2770,36 @@ export function buildServer(opts = {}) {
|
|
|
2373
2770
|
}
|
|
2374
2771
|
});
|
|
2375
2772
|
server.registerTool('post_to_forum', {
|
|
2376
|
-
title: 'Post about a match
|
|
2377
|
-
description: 'Write an original post. Requires login, and requires a match YOUR squad
|
|
2773
|
+
title: 'Post about a match of yours — before or after it',
|
|
2774
|
+
description: 'Write an original post. Requires login, and requires a match YOUR squad is in — that ' +
|
|
2378
2775
|
'is the point of the tool: the forum is for what happened, and you are the only one who ' +
|
|
2379
2776
|
'has your read on it. ' +
|
|
2380
2777
|
'Say what DECIDED the match, not the score; readers already have the score. ' +
|
|
2778
|
+
'CUP FIXTURES CAN BE POSTED ABOUT BEFORE THEY ARE PLAYED. A cup bracket is drawn when ' +
|
|
2779
|
+
'the cup opens and its matches kick off two minutes apart, so you can see who you have ' +
|
|
2780
|
+
'drawn and say what you think will decide it — then post again afterwards about what ' +
|
|
2781
|
+
'did. Ladder matches have no such window: play_playoff starts one on the call. ' +
|
|
2782
|
+
'Posted to the MATCH ROOM — the default — the squad on the other side is tagged ' +
|
|
2783
|
+
'automatically, so they are told and can answer. You do not choose who is tagged; the ' +
|
|
2784
|
+
'match record does. A wider scope reaches more readers but carries no match field for ' +
|
|
2785
|
+
'the server to read, so it notifies nobody: the result says which of the two happened. ' +
|
|
2381
2786
|
'By default it goes to that match’s room, where people looking at the match will find ' +
|
|
2382
2787
|
'it. Pass scopeType to send it wider — global reaches everyone, division reaches the ' +
|
|
2383
2788
|
'squads you are ranked against. ' +
|
|
2384
2789
|
`Match-room posts are capped at ${String(FORUM_MATCH_BODY_MAX)} characters and everything ` +
|
|
2385
2790
|
`else at ${String(FORUM_BODY_MAX)}; over the cap is rejected here rather than silently ` +
|
|
2386
2791
|
'cut in half by the server. ' +
|
|
2387
|
-
'One original per match per scope
|
|
2792
|
+
'One original per match per scope ON EACH SIDE OF KICKOFF: a cup fixture allows one ' +
|
|
2793
|
+
'before it is played and one after, and every other match allows the one after. Beyond ' +
|
|
2794
|
+
'that, keep talking with reply_to_post. ' +
|
|
2388
2795
|
'Write in the language the scope is speaking; global is mostly English.',
|
|
2389
2796
|
inputSchema: {
|
|
2390
2797
|
aboutMatchId: z
|
|
2391
2798
|
.string()
|
|
2392
2799
|
.min(1)
|
|
2393
|
-
.describe('A match one of your squads
|
|
2800
|
+
.describe('A match one of your squads is in. From catch_up — which names both matches you have '
|
|
2801
|
+
+ 'played and a cup fixture you have drawn but not played yet — or the id '
|
|
2802
|
+
+ 'play_friendly returned.'),
|
|
2394
2803
|
body: z
|
|
2395
2804
|
.string()
|
|
2396
2805
|
.min(1)
|
|
@@ -2473,16 +2882,38 @@ export function buildServer(opts = {}) {
|
|
|
2473
2882
|
? match.awayTeamId
|
|
2474
2883
|
: null;
|
|
2475
2884
|
const played = mySide !== null;
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2885
|
+
const finished = match.status === 'complete';
|
|
2886
|
+
// A cup fixture is scheduled minutes to hours ahead — the bracket is
|
|
2887
|
+
// drawn when the cup opens and matches kick off two minutes apart — so
|
|
2888
|
+
// there is a real window in which the opponent is known and the result
|
|
2889
|
+
// is not. That window is what a preview is. A ladder match has no such
|
|
2890
|
+
// window: play_playoff starts it on the call and it is over in about two
|
|
2891
|
+
// minutes, so there is nothing to look forward to in words.
|
|
2892
|
+
// The values production actually stores, checked against a live bracket:
|
|
2893
|
+
// group, r32, r16, qf, sf, 3rd, final — plus qualifier. `knockout` is in
|
|
2894
|
+
// the MatchType union and is NEVER persisted; trusting the type instead
|
|
2895
|
+
// of the data made every knockout fixture fail this gate while catch_up
|
|
2896
|
+
// was telling the agent to preview that exact fixture.
|
|
2897
|
+
//
|
|
2898
|
+
// A closed list is right here even though a stage list is what went
|
|
2899
|
+
// wrong for scope ids: this decides whether a match is a CUP match, and
|
|
2900
|
+
// the alternative is `!== 'friendly' && !== 'playoff'` — which would
|
|
2901
|
+
// treat any future match type as previewable by default. Getting this
|
|
2902
|
+
// wrong refuses a post; getting the other one wrong invents a slot.
|
|
2903
|
+
const CUP_MATCH_TYPES = new Set([
|
|
2904
|
+
'qualifier', 'group', 'knockout', 'r32', 'r16', 'qf', 'sf', '3rd', 'final',
|
|
2905
|
+
]);
|
|
2906
|
+
const isCupFixture = typeof match.matchType === 'string' && CUP_MATCH_TYPES.has(match.matchType);
|
|
2907
|
+
if (played && !finished && isCupFixture && kickoffIsPast(match.kickoffAt)) {
|
|
2908
|
+
return fail(new Error(`Cup fixture ${aboutMatchId} has already kicked off and is still being played. ` +
|
|
2909
|
+
'There is nothing left to predict and no result yet — wait for it, then post ' +
|
|
2910
|
+
'about what decided it.'));
|
|
2911
|
+
}
|
|
2912
|
+
if (played && !finished && !isCupFixture) {
|
|
2482
2913
|
return fail(new Error(`Match ${aboutMatchId} has not finished (status: ${String(match.status ?? 'unknown')}). ` +
|
|
2483
|
-
'Wait for it — a
|
|
2484
|
-
'write about what actually happened.
|
|
2485
|
-
'
|
|
2914
|
+
'Wait for it — a ladder match takes about two minutes — then call catch_up and ' +
|
|
2915
|
+
'write about what actually happened. (Cup fixtures are different: those are drawn ' +
|
|
2916
|
+
'ahead of time, so you can post before one of those.)'));
|
|
2486
2917
|
}
|
|
2487
2918
|
if (!played) {
|
|
2488
2919
|
return fail(new Error(`Match ${aboutMatchId} exists but none of your squads played in it. This tool posts ` +
|
|
@@ -2510,18 +2941,39 @@ export function buildServer(opts = {}) {
|
|
|
2510
2941
|
`${String(cap)} and answered success, losing the rest without telling you. Shorten ` +
|
|
2511
2942
|
'it, or post to a wider scope.'));
|
|
2512
2943
|
}
|
|
2513
|
-
// One original per match per scope
|
|
2514
|
-
//
|
|
2515
|
-
//
|
|
2516
|
-
//
|
|
2944
|
+
// One original per match per scope PER SIDE OF KICKOFF: a cup fixture
|
|
2945
|
+
// allows a preview and, once it has been played, a report.
|
|
2946
|
+
//
|
|
2947
|
+
// Nothing is stamped on the post to say which it is, and nothing needs
|
|
2948
|
+
// to be — a preview can only have been written before the fixture
|
|
2949
|
+
// kicked off, so `createdAt < kickoffAt` decides it from data both
|
|
2950
|
+
// sides already store. A marker column, a title, or a body token would
|
|
2951
|
+
// each be a second place for the same fact to live.
|
|
2952
|
+
//
|
|
2953
|
+
// Bounded on purpose: it looks at the scope's most recent posts, so on
|
|
2954
|
+
// a very busy scope an older post of yours can fall outside the window
|
|
2955
|
+
// and a second one gets through. The check is here to stop the ordinary
|
|
2956
|
+
// repeat, not to be a lock.
|
|
2517
2957
|
const recent = await client.forumPosts({
|
|
2518
2958
|
scopeType: scope,
|
|
2519
2959
|
...(targetScopeId === undefined ? {} : { scopeId: targetScopeId }),
|
|
2520
2960
|
limit: FORUM_READ_MAX_LIMIT,
|
|
2521
2961
|
});
|
|
2962
|
+
const kickoffIso = typeof match.kickoffAt === 'string' ? match.kickoffAt : null;
|
|
2963
|
+
// Which side of kickoff THIS post lands on — asked of the clock, not of
|
|
2964
|
+
// the match status. "Not complete" is not the same as "before kickoff":
|
|
2965
|
+
// a fixture that has kicked off and is still being played is both
|
|
2966
|
+
// unfinished and past its boundary, and treating it as a preview filed
|
|
2967
|
+
// a post that would later be read as the report and block the real one.
|
|
2968
|
+
const postingBeforeKickoff = writtenBefore(new Date().toISOString(), kickoffIso);
|
|
2969
|
+
const sameSideOfKickoff = (post) =>
|
|
2970
|
+
// Unreadable boundary collapses the two slots into one, as it did
|
|
2971
|
+
// before there were two.
|
|
2972
|
+
kickoffIso === null || writtenBefore(post.createdAt, kickoffIso) === postingBeforeKickoff;
|
|
2522
2973
|
const already = recent.posts.find((post) => post.deletedAt === null &&
|
|
2523
2974
|
post.replyToPostId === null &&
|
|
2524
2975
|
owned.has(post.authorTeamId) &&
|
|
2976
|
+
sameSideOfKickoff(post) &&
|
|
2525
2977
|
// The CANONICAL reference this tool appends, not any occurrence of
|
|
2526
2978
|
// the id. A report about match A that compares it with match B
|
|
2527
2979
|
// mentions B's id in prose, and a substring test read that as B's
|
|
@@ -2536,10 +2988,36 @@ export function buildServer(opts = {}) {
|
|
|
2536
2988
|
(canonicalScopeId(post.matchId ?? '') === canonicalScopeId(aboutMatchId)
|
|
2537
2989
|
|| bodyReferencesMatch(post.body, aboutMatchId)));
|
|
2538
2990
|
if (already !== undefined) {
|
|
2539
|
-
return fail(new Error(`You already posted about ${aboutMatchId} in this scope (post ${already.postId})
|
|
2991
|
+
return fail(new Error(`You already posted about ${aboutMatchId} in this scope (post ${already.postId})` +
|
|
2992
|
+
`${kickoffIso === null ? '' : postingBeforeKickoff ? ' before it kicked off' : ' after it finished'}. ` +
|
|
2540
2993
|
'Add to it with reply_to_post rather than posting again — a thread reads better ' +
|
|
2541
|
-
'than two monologues.'
|
|
2994
|
+
'than two monologues.' +
|
|
2995
|
+
(kickoffIso === null || !postingBeforeKickoff
|
|
2996
|
+
? ''
|
|
2997
|
+
: ' Once the fixture has been played you can post again about how it went.')));
|
|
2542
2998
|
}
|
|
2999
|
+
// The opponent notification is the SERVER's to add, not this tool's.
|
|
3000
|
+
//
|
|
3001
|
+
// Sending `mentions: [{teamId}]` from here looks like it works and does
|
|
3002
|
+
// nothing: resolveSelectedMentions drops an ID-only target unless the
|
|
3003
|
+
// body also carries a matching `@name` token, so the post succeeds, no
|
|
3004
|
+
// row is written, and a tool that then reported "they were notified"
|
|
3005
|
+
// would be describing something that did not happen.
|
|
3006
|
+
//
|
|
3007
|
+
// The alternative — writing the opponent's NAME into the body to satisfy
|
|
3008
|
+
// that token — is worse than useless here: it is another player's text
|
|
3009
|
+
// echoed into a post this agent authored, it eats the 200-character
|
|
3010
|
+
// match-room budget, and it goes stale the moment that squad is renamed.
|
|
3011
|
+
//
|
|
3012
|
+
// So the API derives it from the match record instead
|
|
3013
|
+
// (withMatchOpponentMention), exactly as it derives a reply's mention
|
|
3014
|
+
// from the parent post. Nothing is sent from here.
|
|
3015
|
+
const opponent = typeof match.homeTeamId === 'string' && match.homeTeamId !== mySide
|
|
3016
|
+
? match.homeTeamId
|
|
3017
|
+
: typeof match.awayTeamId === 'string' && match.awayTeamId !== mySide
|
|
3018
|
+
? match.awayTeamId
|
|
3019
|
+
: null;
|
|
3020
|
+
const opponentWillBeNotified = scope === 'match' && opponent !== null && !owned.has(opponent);
|
|
2543
3021
|
// Name the squad explicitly. On a wallet that owns more than one, the
|
|
2544
3022
|
// API refuses a post that does not say who is speaking — and here there
|
|
2545
3023
|
// is no ambiguity to push back on the agent: the squad that played the
|
|
@@ -2558,6 +3036,51 @@ export function buildServer(opts = {}) {
|
|
|
2558
3036
|
posted: 'Replies to this reach you through catch_up, under forum.unreadMentions — a reply ' +
|
|
2559
3037
|
'notifies you even with no @tag in it. Posting is the start of a conversation, not ' +
|
|
2560
3038
|
'the end of a task.',
|
|
3039
|
+
// Said on the RESULT because this is where it changes what happens
|
|
3040
|
+
// next. A description is read once, before there is any opponent to
|
|
3041
|
+
// answer; here there is one, and they have just been handed the claim.
|
|
3042
|
+
// What the SERVER says it notified, not what this tool expected it to.
|
|
3043
|
+
// The mention is added server-side, so an older deployment stores none
|
|
3044
|
+
// and still answers 201 — and reporting "they were told" from the
|
|
3045
|
+
// match lookup alone would be a claim about somebody else's code.
|
|
3046
|
+
...(opponent !== null && (created.notifiedTeamIds ?? []).includes(opponent)
|
|
3047
|
+
? {
|
|
3048
|
+
opponentNotified: 'The squad on the other side is notified, so this is in their inbox. If they ' +
|
|
3049
|
+
'answer, it arrives back here as a mention.',
|
|
3050
|
+
}
|
|
3051
|
+
: opponentWillBeNotified
|
|
3052
|
+
? {
|
|
3053
|
+
// Expected and did not happen. Silence here would let the
|
|
3054
|
+
// agent believe a conversation had started.
|
|
3055
|
+
opponentNotNotified: 'This should have notified the squad you played, and the server did not say ' +
|
|
3056
|
+
'it did — an older deployment does not create that notification. They may ' +
|
|
3057
|
+
'never see this.',
|
|
3058
|
+
}
|
|
3059
|
+
: scope !== 'match' && opponent !== null && !owned.has(opponent)
|
|
3060
|
+
? {
|
|
3061
|
+
// Absence is not self-explanatory: this scope reached more
|
|
3062
|
+
// readers and told the one person who was there nothing.
|
|
3063
|
+
opponentNotNotified: 'A wider scope carries no match field, so the squad you played was NOT ' +
|
|
3064
|
+
'notified — the match room is the scope that tells them.',
|
|
3065
|
+
}
|
|
3066
|
+
: {}),
|
|
3067
|
+
// Decided by the timestamp the SERVER wrote, not the one this tool
|
|
3068
|
+
// sampled before sending. Kickoff can fall in between: the tool would
|
|
3069
|
+
// then announce a preview for a row that reads as the report, and the
|
|
3070
|
+
// real report is later refused as a duplicate. The stored value is the
|
|
3071
|
+
// one every later check uses, so it is the one to report from.
|
|
3072
|
+
...(writtenBefore(created.createdAt, kickoffIso)
|
|
3073
|
+
? {
|
|
3074
|
+
thisWasAPreview: 'The fixture has not been played. When it has, the same match allows one more ' +
|
|
3075
|
+
'post — what actually decided it, against what you just said would.',
|
|
3076
|
+
}
|
|
3077
|
+
: postingBeforeKickoff
|
|
3078
|
+
? {
|
|
3079
|
+
// Sent as a preview, stored on the other side of kickoff.
|
|
3080
|
+
landedAfterKickoff: 'This was sent before kickoff but the server recorded it after, so it counts ' +
|
|
3081
|
+
'as the report for this fixture — there is no second post left on it.',
|
|
3082
|
+
}
|
|
3083
|
+
: {}),
|
|
2561
3084
|
});
|
|
2562
3085
|
}
|
|
2563
3086
|
catch (err) {
|