diff --git a/packages/core/src/highlevel/utils/entities.test.ts b/packages/core/src/highlevel/utils/entities.test.ts
new file mode 100644
index 00000000..9ac4f245
--- /dev/null
+++ b/packages/core/src/highlevel/utils/entities.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest'
+
+import { tl } from '@mtcute/tl'
+
+import { joinTextWithEntities } from './entities.js'
+
+const createEntity = (offset: number, length: number): tl.TypeMessageEntity => {
+ return {
+ _: 'messageEntityBold',
+ offset,
+ length,
+ }
+}
+
+describe('joinTextWithEntities', () => {
+ it('should join text with entities using a string delimiter', () => {
+ expect(
+ joinTextWithEntities(
+ [
+ { text: 'foo bar baz', entities: [createEntity(0, 3), createEntity(4, 3), createEntity(8, 3)] },
+ { text: 'egg spam', entities: [createEntity(4, 4)] },
+ { text: 'very spam', entities: [createEntity(0, 4)] },
+ ],
+ ' 🚀 ',
+ ),
+ ).toEqual({
+ text: 'foo bar baz 🚀 egg spam 🚀 very spam',
+ entities: [
+ createEntity(0, 3),
+ createEntity(4, 3),
+ createEntity(8, 3),
+ createEntity(19, 4),
+ createEntity(27, 4),
+ ],
+ })
+ })
+
+ it('should join text with entities using a TextWithEntities delimiter', () => {
+ expect(
+ joinTextWithEntities(
+ [
+ { text: 'foo bar baz', entities: [createEntity(0, 3), createEntity(4, 3), createEntity(8, 3)] },
+ { text: 'egg spam', entities: [createEntity(4, 4)] },
+ { text: 'very spam', entities: [createEntity(0, 4)] },
+ ],
+ { text: ' 🚀 ', entities: [createEntity(1, 2)] },
+ ),
+ ).toEqual({
+ text: 'foo bar baz 🚀 egg spam 🚀 very spam',
+ entities: [
+ createEntity(0, 3),
+ createEntity(4, 3),
+ createEntity(8, 3),
+ createEntity(12, 2),
+ createEntity(19, 4),
+ createEntity(24, 2),
+ createEntity(27, 4),
+ ],
+ })
+ })
+})
diff --git a/packages/core/src/highlevel/utils/entities.ts b/packages/core/src/highlevel/utils/entities.ts
new file mode 100644
index 00000000..9325b46f
--- /dev/null
+++ b/packages/core/src/highlevel/utils/entities.ts
@@ -0,0 +1,62 @@
+import { tl } from '@mtcute/tl'
+
+import { TextWithEntities } from '../types/misc/entities.js'
+
+/**
+ * Join multiple text parts with entities into a single text with entities,
+ * adjusting the entities' offsets accordingly.
+ *
+ * @param parts List of text parts with entities
+ * @param delim Delimiter to insert between parts
+ * @returns A single text with entities
+ * @example
+ *
+ * ```ts
+ * const scoreboardText = joinTextWithEntities(
+ * apiResult.scoreboard.map((entry) => html`${entry.name}: ${entry.score}`),
+ * html`
`
+ * )
+ * await tg.sendText(chatId, html`scoreboard:
${scoreboardText}`)
+ * ```
+ */
+export function joinTextWithEntities(
+ parts: (string | TextWithEntities)[],
+ delim: string | TextWithEntities = '',
+): TextWithEntities {
+ const textParts: string[] = []
+ const newEntities: tl.TypeMessageEntity[] = []
+
+ let position = 0
+
+ if (typeof delim === 'string') {
+ delim = { text: delim }
+ }
+
+ const pushPart = (part: TextWithEntities) => {
+ textParts.push(part.text)
+ const entitiesOffset = position
+ position += part.text.length
+
+ if (part.entities) {
+ for (const entity of part.entities) {
+ newEntities.push({
+ ...entity,
+ offset: entity.offset + entitiesOffset,
+ })
+ }
+ }
+ }
+
+ for (const part of parts) {
+ if (position > 0) {
+ pushPart(delim)
+ }
+
+ pushPart(typeof part === 'string' ? { text: part } : part)
+ }
+
+ return {
+ text: textParts.join(''),
+ entities: newEntities,
+ }
+}
diff --git a/packages/core/src/highlevel/utils/index.ts b/packages/core/src/highlevel/utils/index.ts
index 705c5131..62cc4319 100644
--- a/packages/core/src/highlevel/utils/index.ts
+++ b/packages/core/src/highlevel/utils/index.ts
@@ -1,5 +1,6 @@
// todo: merge this with the main utils dir?
+export * from './entities.js'
export * from './file-utils.js'
export * from './inline-utils.js'
export * from './inspectable.js'